|
| 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 |
0 commit comments