Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
216 changes: 216 additions & 0 deletions lib/bundlex/lsp/config.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
defmodule Bundlex.LSP.Config do
@moduledoc false
# Generates LSP configuration files (compile_commands.json, compile_flags.txt)
# for C/C++ code analysis tools like clangd.

alias Bundlex.Output

@type compile_command :: %{
required(:directory) => String.t(),
required(:command) => String.t(),
required(:file) => String.t(),
optional(:output) => String.t()
}

@doc """
Generates LSP configuration files from a list of build commands.

## Returns

`{:ok, [{:compile_commands_json, path} | {:compile_flags_txt, path}]}`
or `{:error, reason}` if all writes fail.
"""
@spec generate(commands :: [String.t()], project_dir :: String.t()) ::
{:ok, [{atom, String.t()}]} | {:error, String.t()}
Comment on lines +1 to +24

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I guess this module is not a part of public API, so it should have @moduledoc false and no function @doc

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

You can add @moduledoc false and leave the current moduledoc content as a comment

def generate(commands, project_dir) do
project_dir = Path.expand(project_dir)

{compile_commands, common_flags} =
parse_compile_commands(commands, project_dir)

maybe_commands =
case write_compile_commands_json(compile_commands, project_dir) do
{:ok, path} ->
[{:compile_commands_json, path}]

{:error, reason} ->
Output.warn("Failed to write compile_commands.json: #{reason}")
[]
end

maybe_compile_flags =
case write_compile_flags_txt(common_flags, project_dir) do
{:ok, path} ->
[{:compile_flags_txt, path}]

{:error, reason} ->
Output.warn("Failed to write compile_flags.txt: #{reason}")
[]
end

case maybe_commands ++ maybe_compile_flags do
[] -> {:error, "No configuration files were generated"}
generated -> {:ok, generated}
end
end

defp parse_compile_commands(commands, project_dir) do
{compile_commands, all_flag_sets} =
commands
|> Enum.reject(&skip_command?/1)
|> Enum.flat_map(&parse_entry(&1, project_dir))
|> Enum.unzip()

common_flags =
case all_flag_sets do
[] -> MapSet.new()
[single] -> single
[first | rest] -> Enum.reduce(rest, first, &MapSet.intersection(&2, &1))
end

{compile_commands, common_flags}
end

defp parse_entry(command, project_dir) do
case parse_compile_command(command, project_dir) do
nil ->
[]

info ->
parts = parse_shell_arguments(command)
flags = MapSet.new(extract_flags_from_command(parts))
[{info, flags}]
end
end

# Replaces version-pinned Homebrew Erlang paths with the stable opt/ symlink,
# e.g. /opt/homebrew/Cellar/erlang/28.4.1/lib/erlang → /opt/homebrew/opt/erlang/lib/erlang
# No-op on non-Homebrew systems.
defp normalize_homebrew_erlang_path(str) do
Regex.replace(
~r|/opt/homebrew/Cellar/erlang/[^/]+/lib/erlang|,
str,
"/opt/homebrew/opt/erlang/lib/erlang"
)
end

# Checks the basename of the first token so that tools installed under a full path
# (e.g. /usr/bin/ar) are correctly skipped rather than only bare invocations.
defp skip_command?(command) do
binary =
case parse_shell_arguments(command) do
[] -> ""
[first | _rest] -> Path.basename(first)
end

binary in ~w[mkdir rm ar] ||

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 one will return false for /usr/bin/ar 🤔

@khamilowicz khamilowicz May 22, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

how so? ok, if it was actually /usr/bin/ar string, then sure, but https://github.com/membraneframework/bundlex/blob/work-on-lsp/lib/bundlex/toolchain/common/unix.ex#L76-L76 returns bare ar.
We could use String.ends_with?, but, since we are controlling emitted commands, and they are within the same repo, IMO it is fine. The comment could be less confusing.

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.

You are right, I skipped this Path.basename part

(String.contains?(command, " -o ") &&
!String.contains?(command, " -c ") &&
(String.contains?(command, ".so") ||
String.contains?(command, ".dll") ||
String.contains?(command, ".dylib")))
end

defp parse_compile_command(command, project_dir) do
parts = parse_shell_arguments(command)
{source_file, output_file} = extract_source_and_output(parts)

case source_file do
nil ->
nil

source ->
source = to_absolute_path(source, project_dir)
output = output_file && to_absolute_path(output_file, project_dir)

%{
directory: Path.dirname(source),
command: Enum.join(parts, " "),
file: source,
output: output
}
end
end

defp extract_source_and_output(parts) do
{source, output, _after_o} =
Enum.reduce(parts, {nil, nil, false}, fn part, {source, output, after_o} ->
cond do
after_o && output == nil -> {source, part, false}
part == "-o" -> {source, output, true}
source == nil && source_file?(part) -> {part, output, after_o}
true -> {source, output, after_o}
end
end)

{source, output}
end

defp to_absolute_path(path, base_dir) do
if Path.absname(path) == path, do: path, else: Path.join(base_dir, path)
end

# Naive tokenizer: strips quotes then splits on whitespace. Paths containing spaces will be corrupted.
defp parse_shell_arguments(command) do
command
|> String.replace("\"", "")
|> String.replace("'", "")
|> String.split()
end

defp source_file?(str) do
String.ends_with?(str, ".c") ||
String.ends_with?(str, ".cpp") ||
String.ends_with?(str, ".cc") ||
String.ends_with?(str, ".cxx")
end

defp extract_flags_from_command(parts) do
parts
|> Enum.filter(fn part ->
String.starts_with?(part, "-") && part != "-o" && part != "-c"
end)
|> Enum.map(&normalize_homebrew_erlang_path/1)
end

defp write_compile_commands_json(commands, project_dir) do
path = Path.join(project_dir, "compile_commands.json")

entries =
Enum.map(commands, fn cmd ->
entry = %{
# Use the directory the compiler was invoked from so clangd resolves relative includes correctly.
"directory" => cmd.directory,
"command" => normalize_homebrew_erlang_path(cmd.command),
"file" => cmd.file
}

if cmd.output, do: Map.put(entry, "output", cmd.output), else: entry
end)

json = Jason.encode!(entries, pretty: true)

case File.write(path, json) do
:ok ->
Output.info("Generated compile_commands.json at #{path}")
{:ok, path}

{:error, reason} ->
{:error, "Failed to write #{path}: #{inspect(reason)}"}
end
end

defp write_compile_flags_txt(flags, dir) do
path = Path.join(dir, "compile_flags.txt")
content = flags |> MapSet.to_list() |> Enum.sort() |> Enum.join("\n")

case File.write(path, content) do
:ok ->
Output.info("Generated compile_flags.txt at #{path}")
{:ok, path}

{:error, reason} ->
{:error, "Failed to write #{path}: #{inspect(reason)}"}
end
end
end
27 changes: 25 additions & 2 deletions lib/mix/tasks/compile.bundlex.ex
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ defmodule Mix.Tasks.Compile.Bundlex do
Accepts the following command line arguments:
- `--store-scripts` - if set, shell scripts are stored in the project
root folder for further analysis.
- `--generate-lsp-config` - if set, generates `compile_commands.json` and `compile_flags.txt`
for LSP tools like clangd to enable code navigation, autocompletion, and diagnostics.

Add `:bundlex` to compilers in your Mix project to have this task executed
each time the project is compiled.
Expand All @@ -14,11 +16,12 @@ defmodule Mix.Tasks.Compile.Bundlex do

alias Bundlex.{BuildScript, Native, Output, Platform, Project}
alias Bundlex.Helper.MixHelper
alias Bundlex.LSP

@recursive true

@impl true
def run(_args) do
def run(args) do
{:ok, _apps} = Application.ensure_all_started(:bundlex)
commands = []

Expand All @@ -33,6 +36,8 @@ defmodule Mix.Tasks.Compile.Bundlex do
Output.raise("Cannot get project for app: #{inspect(app)}, reason: #{inspect(reason)}")
end

project_dir = File.cwd!()

commands = commands ++ Platform.get_module(platform).toolchain_module().before_all!(platform)

commands =
Expand All @@ -50,13 +55,19 @@ defmodule Mix.Tasks.Compile.Bundlex do
build_script = BuildScript.new(commands)

{cmdline_options, _argv, _errors} =
OptionParser.parse(System.argv(), switches: [store_scripts: :boolean])
OptionParser.parse(args,
switches: [store_scripts: :boolean, generate_lsp_config: :boolean]
)

if cmdline_options[:store_scripts] do
{:ok, {filename, _script}} = build_script |> BuildScript.store(platform)
Output.info("Stored build script at #{File.cwd!() |> Path.join(filename)}")
end

if cmdline_options[:generate_lsp_config] do
generate_lsp_config(build_script, project_dir)
end

case build_script |> BuildScript.run(platform) do
:ok ->
:ok
Expand All @@ -79,4 +90,16 @@ defmodule Mix.Tasks.Compile.Bundlex do

{:ok, []}
end

defp generate_lsp_config(build_script, project_dir) do
commands = build_script.commands

case LSP.Config.generate(commands, project_dir) do
{:ok, _generated} ->
:ok

{:error, reason} ->
Output.warn("Failed to generate LSP config: #{reason}")
end
end
end
7 changes: 4 additions & 3 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,10 @@ defmodule Bundlex.Mixfile do
{:req, ">= 0.4.0"},
{:elixir_uuid, "~> 1.2"},
{:zarex, "~> 1.0"},
{:ex_doc, "~> 0.21", only: :dev, runtime: false},
{:dialyxir, "~> 1.0", only: :dev, runtime: false},
{:credo, "~> 1.6", only: :dev, runtime: false}
{:jason, "~> 1.4"},
{:ex_doc, ">= 0.0.0", only: :dev, runtime: false},
{:dialyxir, ">= 0.0.0", only: :dev, runtime: false},
{:credo, ">= 0.0.0", only: :dev, runtime: false}
]
end
end
Loading