Skip to content

Commit 08aea65

Browse files
authored
Merge pull request #143 from membraneframework/work-on-lsp
Add LSP config generation for C/C++ native code
2 parents fa5123f + b650ec2 commit 08aea65

3 files changed

Lines changed: 245 additions & 5 deletions

File tree

lib/bundlex/lsp/config.ex

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
defmodule Bundlex.LSP.Config do
2+
@moduledoc false
3+
# Generates LSP configuration files (compile_commands.json, compile_flags.txt)
4+
# for C/C++ code analysis tools like clangd.
5+
6+
alias Bundlex.Output
7+
8+
@type compile_command :: %{
9+
required(:directory) => String.t(),
10+
required(:command) => String.t(),
11+
required(:file) => String.t(),
12+
optional(:output) => String.t()
13+
}
14+
15+
@doc """
16+
Generates LSP configuration files from a list of build commands.
17+
18+
## Returns
19+
20+
`{:ok, [{:compile_commands_json, path} | {:compile_flags_txt, path}]}`
21+
or `{:error, reason}` if all writes fail.
22+
"""
23+
@spec generate(commands :: [String.t()], project_dir :: String.t()) ::
24+
{:ok, [{atom, String.t()}]} | {:error, String.t()}
25+
def generate(commands, project_dir) do
26+
project_dir = Path.expand(project_dir)
27+
28+
{compile_commands, common_flags} =
29+
parse_compile_commands(commands, project_dir)
30+
31+
maybe_commands =
32+
case write_compile_commands_json(compile_commands, project_dir) do
33+
{:ok, path} ->
34+
[{:compile_commands_json, path}]
35+
36+
{:error, reason} ->
37+
Output.warn("Failed to write compile_commands.json: #{reason}")
38+
[]
39+
end
40+
41+
maybe_compile_flags =
42+
case write_compile_flags_txt(common_flags, project_dir) do
43+
{:ok, path} ->
44+
[{:compile_flags_txt, path}]
45+
46+
{:error, reason} ->
47+
Output.warn("Failed to write compile_flags.txt: #{reason}")
48+
[]
49+
end
50+
51+
case maybe_commands ++ maybe_compile_flags do
52+
[] -> {:error, "No configuration files were generated"}
53+
generated -> {:ok, generated}
54+
end
55+
end
56+
57+
defp parse_compile_commands(commands, project_dir) do
58+
{compile_commands, all_flag_sets} =
59+
commands
60+
|> Enum.reject(&skip_command?/1)
61+
|> Enum.flat_map(&parse_entry(&1, project_dir))
62+
|> Enum.unzip()
63+
64+
common_flags =
65+
case all_flag_sets do
66+
[] -> MapSet.new()
67+
[single] -> single
68+
[first | rest] -> Enum.reduce(rest, first, &MapSet.intersection(&2, &1))
69+
end
70+
71+
{compile_commands, common_flags}
72+
end
73+
74+
defp parse_entry(command, project_dir) do
75+
case parse_compile_command(command, project_dir) do
76+
nil ->
77+
[]
78+
79+
info ->
80+
parts = parse_shell_arguments(command)
81+
flags = MapSet.new(extract_flags_from_command(parts))
82+
[{info, flags}]
83+
end
84+
end
85+
86+
# Replaces version-pinned Homebrew Erlang paths with the stable opt/ symlink,
87+
# e.g. /opt/homebrew/Cellar/erlang/28.4.1/lib/erlang → /opt/homebrew/opt/erlang/lib/erlang
88+
# No-op on non-Homebrew systems.
89+
defp normalize_homebrew_erlang_path(str) do
90+
Regex.replace(
91+
~r|/opt/homebrew/Cellar/erlang/[^/]+/lib/erlang|,
92+
str,
93+
"/opt/homebrew/opt/erlang/lib/erlang"
94+
)
95+
end
96+
97+
# Checks the basename of the first token so that tools installed under a full path
98+
# (e.g. /usr/bin/ar) are correctly skipped rather than only bare invocations.
99+
defp skip_command?(command) do
100+
binary =
101+
case parse_shell_arguments(command) do
102+
[] -> ""
103+
[first | _rest] -> Path.basename(first)
104+
end
105+
106+
binary in ~w[mkdir rm ar] ||
107+
(String.contains?(command, " -o ") &&
108+
!String.contains?(command, " -c ") &&
109+
(String.contains?(command, ".so") ||
110+
String.contains?(command, ".dll") ||
111+
String.contains?(command, ".dylib")))
112+
end
113+
114+
defp parse_compile_command(command, project_dir) do
115+
parts = parse_shell_arguments(command)
116+
{source_file, output_file} = extract_source_and_output(parts)
117+
118+
case source_file do
119+
nil ->
120+
nil
121+
122+
source ->
123+
source = to_absolute_path(source, project_dir)
124+
output = output_file && to_absolute_path(output_file, project_dir)
125+
126+
%{
127+
directory: Path.dirname(source),
128+
command: Enum.join(parts, " "),
129+
file: source,
130+
output: output
131+
}
132+
end
133+
end
134+
135+
defp extract_source_and_output(parts) do
136+
{source, output, _after_o} =
137+
Enum.reduce(parts, {nil, nil, false}, fn part, {source, output, after_o} ->
138+
cond do
139+
after_o && output == nil -> {source, part, false}
140+
part == "-o" -> {source, output, true}
141+
source == nil && source_file?(part) -> {part, output, after_o}
142+
true -> {source, output, after_o}
143+
end
144+
end)
145+
146+
{source, output}
147+
end
148+
149+
defp to_absolute_path(path, base_dir) do
150+
if Path.absname(path) == path, do: path, else: Path.join(base_dir, path)
151+
end
152+
153+
# Naive tokenizer: strips quotes then splits on whitespace. Paths containing spaces will be corrupted.
154+
defp parse_shell_arguments(command) do
155+
command
156+
|> String.replace("\"", "")
157+
|> String.replace("'", "")
158+
|> String.split()
159+
end
160+
161+
defp source_file?(str) do
162+
String.ends_with?(str, ".c") ||
163+
String.ends_with?(str, ".cpp") ||
164+
String.ends_with?(str, ".cc") ||
165+
String.ends_with?(str, ".cxx")
166+
end
167+
168+
defp extract_flags_from_command(parts) do
169+
parts
170+
|> Enum.filter(fn part ->
171+
String.starts_with?(part, "-") && part != "-o" && part != "-c"
172+
end)
173+
|> Enum.map(&normalize_homebrew_erlang_path/1)
174+
end
175+
176+
defp write_compile_commands_json(commands, project_dir) do
177+
path = Path.join(project_dir, "compile_commands.json")
178+
179+
entries =
180+
Enum.map(commands, fn cmd ->
181+
entry = %{
182+
# Use the directory the compiler was invoked from so clangd resolves relative includes correctly.
183+
"directory" => cmd.directory,
184+
"command" => normalize_homebrew_erlang_path(cmd.command),
185+
"file" => cmd.file
186+
}
187+
188+
if cmd.output, do: Map.put(entry, "output", cmd.output), else: entry
189+
end)
190+
191+
json = Jason.encode!(entries, pretty: true)
192+
193+
case File.write(path, json) do
194+
:ok ->
195+
Output.info("Generated compile_commands.json at #{path}")
196+
{:ok, path}
197+
198+
{:error, reason} ->
199+
{:error, "Failed to write #{path}: #{inspect(reason)}"}
200+
end
201+
end
202+
203+
defp write_compile_flags_txt(flags, dir) do
204+
path = Path.join(dir, "compile_flags.txt")
205+
content = flags |> MapSet.to_list() |> Enum.sort() |> Enum.join("\n")
206+
207+
case File.write(path, content) do
208+
:ok ->
209+
Output.info("Generated compile_flags.txt at #{path}")
210+
{:ok, path}
211+
212+
{:error, reason} ->
213+
{:error, "Failed to write #{path}: #{inspect(reason)}"}
214+
end
215+
end
216+
end

lib/mix/tasks/compile.bundlex.ex

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ defmodule Mix.Tasks.Compile.Bundlex do
66
Accepts the following command line arguments:
77
- `--store-scripts` - if set, shell scripts are stored in the project
88
root folder for further analysis.
9+
- `--generate-lsp-config` - if set, generates `compile_commands.json` and `compile_flags.txt`
10+
for LSP tools like clangd to enable code navigation, autocompletion, and diagnostics.
911
1012
Add `:bundlex` to compilers in your Mix project to have this task executed
1113
each time the project is compiled.
@@ -14,11 +16,12 @@ defmodule Mix.Tasks.Compile.Bundlex do
1416

1517
alias Bundlex.{BuildScript, Native, Output, Platform, Project}
1618
alias Bundlex.Helper.MixHelper
19+
alias Bundlex.LSP
1720

1821
@recursive true
1922

2023
@impl true
21-
def run(_args) do
24+
def run(args) do
2225
{:ok, _apps} = Application.ensure_all_started(:bundlex)
2326
commands = []
2427

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

39+
project_dir = File.cwd!()
40+
3641
commands = commands ++ Platform.get_module(platform).toolchain_module().before_all!(platform)
3742

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

5257
{cmdline_options, _argv, _errors} =
53-
OptionParser.parse(System.argv(), switches: [store_scripts: :boolean])
58+
OptionParser.parse(args,
59+
switches: [store_scripts: :boolean, generate_lsp_config: :boolean]
60+
)
5461

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

67+
if cmdline_options[:generate_lsp_config] do
68+
generate_lsp_config(build_script, project_dir)
69+
end
70+
6071
case build_script |> BuildScript.run(platform) do
6172
:ok ->
6273
:ok
@@ -79,4 +90,16 @@ defmodule Mix.Tasks.Compile.Bundlex do
7990

8091
{:ok, []}
8192
end
93+
94+
defp generate_lsp_config(build_script, project_dir) do
95+
commands = build_script.commands
96+
97+
case LSP.Config.generate(commands, project_dir) do
98+
{:ok, _generated} ->
99+
:ok
100+
101+
{:error, reason} ->
102+
Output.warn("Failed to generate LSP config: #{reason}")
103+
end
104+
end
82105
end

mix.exs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,9 +74,10 @@ defmodule Bundlex.Mixfile do
7474
{:req, ">= 0.4.0"},
7575
{:elixir_uuid, "~> 1.2"},
7676
{:zarex, "~> 1.0"},
77-
{:ex_doc, "~> 0.21", only: :dev, runtime: false},
78-
{:dialyxir, "~> 1.0", only: :dev, runtime: false},
79-
{:credo, "~> 1.6", only: :dev, runtime: false}
77+
{:jason, "~> 1.4"},
78+
{:ex_doc, ">= 0.0.0", only: :dev, runtime: false},
79+
{:dialyxir, ">= 0.0.0", only: :dev, runtime: false},
80+
{:credo, ">= 0.0.0", only: :dev, runtime: false}
8081
]
8182
end
8283
end

0 commit comments

Comments
 (0)