erlang.ex (15909B)
1 defmodule ExDoc.Language.Erlang do 2 @moduledoc false 3 4 @behaviour ExDoc.Language 5 6 alias ExDoc.{Autolink, Refs} 7 8 @impl true 9 def module_data(module, docs_chunk, _config) do 10 # Make sure the module is loaded for future checks 11 _ = Code.ensure_loaded(module) 12 id = Atom.to_string(module) 13 abst_code = get_abstract_code(module) 14 line = find_module_line(module, abst_code) 15 type = module_type(module) 16 optional_callbacks = type == :behaviour && module.behaviour_info(:optional_callbacks) 17 18 %{ 19 module: module, 20 docs: docs_chunk, 21 language: __MODULE__, 22 id: id, 23 title: id, 24 type: type, 25 line: line, 26 callback_types: [:callback], 27 nesting_info: nil, 28 private: %{ 29 abst_code: abst_code, 30 specs: get_specs(module), 31 callbacks: get_callbacks(module), 32 optional_callbacks: optional_callbacks 33 } 34 } 35 end 36 37 @impl true 38 def function_data(entry, module_data) do 39 {{kind, name, arity}, _anno, _signature, doc_content, _metadata} = entry 40 41 # TODO: Edoc on Erlang/OTP24.1+ includes private functions in 42 # the chunk, so we manually yank them out for now. 43 if kind == :function and doc_content != :hidden and 44 function_exported?(module_data.module, name, arity) do 45 function_data(name, arity, doc_content, module_data) 46 else 47 :skip 48 end 49 end 50 51 defp function_data(name, arity, _doc_content, module_data) do 52 specs = 53 case Map.fetch(module_data.private.specs, {name, arity}) do 54 {:ok, specs} -> 55 [{:attribute, 0, :spec, {{name, arity}, specs}}] 56 57 :error -> 58 [] 59 end 60 61 %{ 62 doc_fallback: fn -> nil end, 63 extra_annotations: [], 64 line: nil, 65 specs: specs 66 } 67 end 68 69 @impl true 70 def callback_data(entry, module_data) do 71 {{_kind, name, arity}, anno, signature, _doc, _metadata} = entry 72 73 extra_annotations = 74 if {name, arity} in module_data.private.optional_callbacks, do: ["optional"], else: [] 75 76 specs = 77 case Map.fetch(module_data.private.callbacks, {name, arity}) do 78 {:ok, specs} -> 79 [{:attribute, 0, :callback, {{name, arity}, specs}}] 80 81 :error -> 82 [] 83 end 84 85 %{ 86 line: anno_line(anno), 87 signature: signature, 88 specs: specs, 89 extra_annotations: extra_annotations 90 } 91 end 92 93 @impl true 94 def type_data(entry, module_data) do 95 {{kind, name, arity}, anno, signature, _doc, _metadata} = entry 96 97 case ExDoc.Language.Elixir.type_from_module_data(module_data, name, arity) do 98 %{} = map -> 99 %{ 100 type: map.type, 101 line: map.line, 102 spec: {:attribute, 0, map.type, map.spec}, 103 signature: signature 104 } 105 106 nil -> 107 %{ 108 type: kind, 109 line: anno_line(anno), 110 spec: nil, 111 signature: signature 112 } 113 end 114 end 115 116 @impl true 117 def autolink_doc(ast, opts) do 118 config = struct!(Autolink, opts) 119 walk_doc(ast, config) 120 end 121 122 @impl true 123 def autolink_spec(nil, _opts) do 124 nil 125 end 126 127 def autolink_spec({:attribute, _, :opaque, ast}, _opts) do 128 {name, _, args} = ast 129 130 args = 131 for arg <- args do 132 {:var, _, name} = arg 133 Atom.to_string(name) 134 end 135 |> Enum.intersperse(", ") 136 137 IO.iodata_to_binary([Atom.to_string(name), "(", args, ")"]) 138 end 139 140 def autolink_spec(ast, opts) do 141 config = struct!(Autolink, opts) 142 143 {name, quoted} = 144 case ast do 145 {:attribute, _, kind, {{name, _arity}, ast}} when kind in [:spec, :callback] -> 146 {name, Enum.map(ast, &Code.Typespec.spec_to_quoted(name, &1))} 147 148 {:attribute, _, :type, ast} -> 149 {name, _, _} = ast 150 {name, Code.Typespec.type_to_quoted(ast)} 151 end 152 153 formatted = format_spec(ast) 154 autolink_spec(quoted, name, formatted, config) 155 end 156 157 @impl true 158 def highlight_info() do 159 %{ 160 language_name: "erlang", 161 lexer: Makeup.Lexers.ErlangLexer, 162 opts: [] 163 } 164 end 165 166 @impl true 167 def format_spec_attribute(%ExDoc.TypeNode{type: type}), do: "-#{type}" 168 def format_spec_attribute(%ExDoc.FunctionNode{type: :callback}), do: "-callback" 169 def format_spec_attribute(%ExDoc.FunctionNode{}), do: "-spec" 170 171 ## Shared between Erlang & Elixir 172 173 @doc false 174 def get_abstract_code(module) do 175 case :code.get_object_code(module) do 176 {^module, binary, _file} -> 177 case :beam_lib.chunks(binary, [:abstract_code]) do 178 {:ok, {_, [{:abstract_code, {_vsn, abstract_code}}]}} -> abstract_code 179 _otherwise -> [] 180 end 181 182 :error -> 183 [] 184 end 185 end 186 187 @doc false 188 def find_module_line(module, abst_code) do 189 Enum.find_value(abst_code, fn 190 {:attribute, anno, :module, ^module} -> anno_line(anno) 191 _ -> nil 192 end) 193 end 194 195 # Returns a map of {name, arity} => spec. 196 def get_specs(module) do 197 case Code.Typespec.fetch_specs(module) do 198 {:ok, specs} -> Map.new(specs) 199 :error -> %{} 200 end 201 end 202 203 def get_callbacks(module) do 204 case Code.Typespec.fetch_callbacks(module) do 205 {:ok, callbacks} -> Map.new(callbacks) 206 :error -> %{} 207 end 208 end 209 210 ## Autolink 211 212 defp walk_doc(list, config) when is_list(list) do 213 Enum.map(list, &walk_doc(&1, config)) 214 end 215 216 defp walk_doc(binary, _) when is_binary(binary) do 217 binary 218 end 219 220 defp walk_doc({:a, attrs, inner, _meta} = ast, config) do 221 case attrs[:rel] do 222 "https://erlang.org/doc/link/seeerl" -> 223 {fragment, url} = extract_fragment(attrs[:href] || "") 224 225 case String.split(url, ":") do 226 [module] -> 227 autolink(:module, module, fragment, inner, config) 228 229 [app, module] -> 230 inner = strip_app(inner, app) 231 autolink(:module, module, fragment, inner, config) 232 233 _ -> 234 warn_ref(attrs[:href], config) 235 inner 236 end 237 238 "https://erlang.org/doc/link/seemfa" -> 239 {kind, url} = 240 case String.split(attrs[:href], "Module:") do 241 [url] -> {:function, url} 242 [left, right] -> {:callback, left <> right} 243 end 244 245 case String.split(url, ":") do 246 [mfa] -> 247 autolink(kind, mfa, "", inner, config) 248 249 [app, mfa] -> 250 inner = strip_app(inner, app) 251 autolink(kind, mfa, "", inner, config) 252 end 253 254 "https://erlang.org/doc/link/seetype" -> 255 case String.split(attrs[:href], ":") do 256 [type] -> 257 autolink(:type, type, "", inner, config) 258 259 [app, type] -> 260 inner = strip_app(inner, app) 261 autolink(:type, type, "", inner, config) 262 end 263 264 "https://erlang.org/doc/link/" <> see -> 265 warn_ref(attrs[:href] <> " (#{see})", config) 266 inner 267 268 _ -> 269 ast 270 end 271 end 272 273 defp walk_doc({tag, attrs, ast, meta}, config) do 274 {tag, attrs, walk_doc(ast, config), meta} 275 end 276 277 defp extract_fragment(url) do 278 case String.split(url, "#", parts: 2) do 279 [url] -> {"", url} 280 [url, fragment] -> {"#" <> fragment, url} 281 end 282 end 283 284 defp strip_app([{:code, attrs, [code], meta}], app) do 285 [{:code, attrs, strip_app(code, app), meta}] 286 end 287 288 defp strip_app(code, app) when is_binary(code) do 289 String.trim_leading(code, "//#{app}/") 290 end 291 292 defp strip_app(other, _app) do 293 other 294 end 295 296 defp warn_ref(href, config) do 297 message = "invalid reference: #{href}" 298 Autolink.maybe_warn(message, config, nil, %{}) 299 end 300 301 defp autolink(kind, string, fragment, inner, config) do 302 if url = url(kind, string, config) do 303 {:a, [href: url <> fragment], inner, %{}} 304 else 305 inner 306 end 307 end 308 309 defp url(:module, string, config) do 310 ref = {:module, String.to_atom(string)} 311 do_url(ref, string, config) 312 end 313 314 defp url(kind, string, config) do 315 [module, name, arity] = 316 case String.split(string, ["#", "/"]) do 317 [module, name, arity] -> 318 [module, name, arity] 319 320 # this is what docgen_xml_to_chunk returns 321 [module, name] when kind == :type -> 322 # TODO: don't assume 0-arity, instead find first {:type, module, name, arity} ref 323 # and use that arity. 324 [module, name, "0"] 325 end 326 327 name = String.to_atom(name) 328 arity = String.to_integer(arity) 329 330 original_text = 331 if kind == :type and arity == 0 do 332 "#{name}()" 333 else 334 "#{name}/#{arity}" 335 end 336 337 if module == "" do 338 ref = {kind, config.current_module, name, arity} 339 visibility = Refs.get_visibility(ref) 340 341 if visibility == :public do 342 final_url({kind, name, arity}, config) 343 else 344 Autolink.maybe_warn(ref, config, visibility, %{original_text: original_text}) 345 nil 346 end 347 else 348 ref = {kind, String.to_atom(module), name, arity} 349 original_text = "#{module}:#{original_text}" 350 do_url(ref, original_text, config) 351 end 352 end 353 354 defp do_url(ref, original_text, config) do 355 visibility = Refs.get_visibility(ref) 356 357 # TODO: type with content = %{} in otp xml is marked as :hidden, it should be :public 358 359 if visibility == :public or (visibility == :hidden and elem(ref, 0) == :type) do 360 final_url(ref, config) 361 else 362 Autolink.maybe_warn(ref, config, visibility, %{original_text: original_text}) 363 nil 364 end 365 end 366 367 defp final_url({:module, module}, config) do 368 tool = Autolink.tool(module, config) 369 Autolink.app_module_url(tool, module, config) 370 end 371 372 defp final_url({kind, name, arity}, _config) do 373 fragment(:ex_doc, kind, name, arity) 374 end 375 376 defp final_url({kind, module, name, arity}, config) do 377 tool = Autolink.tool(module, config) 378 module_url = Autolink.app_module_url(tool, module, config) 379 # TODO: fix me 380 module_url = String.trim_trailing(module_url, "#content") 381 module_url <> fragment(tool, kind, name, arity) 382 end 383 384 defp fragment(:otp, :function, name, arity) do 385 "##{name}-#{arity}" 386 end 387 388 defp fragment(:otp, :callback, name, arity) do 389 "#Module:#{name}-#{arity}" 390 end 391 392 defp fragment(:otp, :type, name, _arity) do 393 "#type-#{name}" 394 end 395 396 defp fragment(:ex_doc, :function, name, arity) do 397 "##{name}/#{arity}" 398 end 399 400 defp fragment(:ex_doc, :callback, name, arity) do 401 "#c:#{name}/#{arity}" 402 end 403 404 defp fragment(:ex_doc, :type, name, arity) do 405 "#t:#{name}/#{arity}" 406 end 407 408 # Traverses quoted and formatted string of the typespec AST, replacing refs with links. 409 # 410 # Let's say we have this typespec: 411 # 412 # -spec f(X) -> #{atom() => bar(), integer() => X}. 413 # 414 # We traverse the AST and find types and their string representations: 415 # 416 # -spec f(X) -> #{atom() => bar(), integer() => X}. 417 # ^^^^ ^^^ ^^^^^^^ 418 # 419 # atom/0 => atom 420 # bar/0 => bar 421 # integer/0 => integer 422 # 423 # We then traverse the formatted string, *in order*, replacing the type strings with links: 424 # 425 # "atom(" => "atom(" 426 # "bar(" => "<a>bar</a>(" 427 # "integer(" => "integer(" 428 # 429 # Finally we end up with: 430 # 431 # -spec f(X) -> #{atom() => <a>bar</a>(), integer() => X}. 432 # 433 # All of this hassle is to preserve the original *text layout* of the initial representation, 434 # all the spaces, newlines, etc. 435 defp autolink_spec(quoted, name, formatted, config) do 436 acc = 437 for quoted <- List.wrap(quoted) do 438 {_quoted, acc} = 439 Macro.prewalk(quoted, [], fn 440 # module.name(args) 441 {{:., _, [module, name]}, _, args}, acc -> 442 {{:t, [], args}, [{pp({module, name}), {module, name, length(args)}} | acc]} 443 444 {name, _, _}, acc when name in [:<<>>, :..] -> 445 {nil, acc} 446 447 # -1 448 {:-, _, [int]}, acc when is_integer(int) -> 449 {nil, acc} 450 451 # fun() (spec_to_quoted expands it to (... -> any()) 452 {:->, _, [[{name, _, _}], {:any, _, _}]}, acc when name == :... -> 453 {nil, acc} 454 455 # #{x :: t()} 456 {:field_type, _, [name, type]}, acc when is_atom(name) -> 457 {type, acc} 458 459 {name, _, args} = ast, acc when is_atom(name) and is_list(args) -> 460 arity = length(args) 461 462 cond do 463 name in [:"::", :when, :%{}, :{}, :|, :->, :record] -> 464 {ast, acc} 465 466 # %{required(...) => ..., optional(...) => ...} 467 name in [:required, :optional] and arity == 1 -> 468 {ast, acc} 469 470 # name(args) 471 true -> 472 {ast, [{pp(name), {name, arity}} | acc]} 473 end 474 475 other, acc -> 476 {other, acc} 477 end) 478 479 acc 480 |> Enum.reverse() 481 # drop the name of the typespec 482 |> Enum.drop(1) 483 end 484 |> Enum.concat() 485 486 put(acc) 487 488 # Drop and re-add type name (it, the first element in acc, is dropped there too) 489 # 490 # 1. foo() :: bar() 491 # 2. () :: bar() 492 # 3. () :: <a>bar</a>() 493 # 4. foo() :: <a>bar</a>() 494 name = pp(name) 495 formatted = trim_name(formatted, name) 496 formatted = replace(formatted, acc, config) 497 name <> formatted 498 end 499 500 defp trim_name(string, name) do 501 name_size = byte_size(name) 502 binary_part(string, name_size, byte_size(string) - name_size) 503 end 504 505 defp replace(formatted, [], _config) do 506 formatted 507 end 508 509 defp replace(formatted, acc, config) do 510 String.replace(formatted, Enum.map(acc, &"#{elem(&1, 0)}("), fn string -> 511 string = String.trim_trailing(string, "(") 512 {other, ref} = pop() 513 514 if string != other do 515 Autolink.maybe_warn( 516 "internal inconsistency, please submit bug: #{inspect(string)} != #{inspect(other)}", 517 config, 518 nil, 519 nil 520 ) 521 end 522 523 url = 524 case ref do 525 {name, arity} -> 526 visibility = Refs.get_visibility({:type, config.current_module, name, arity}) 527 528 if visibility in [:public, :hidden] do 529 final_url({:type, name, arity}, config) 530 end 531 532 {module, name, arity} -> 533 ref = {:type, module, name, arity} 534 visibility = Refs.get_visibility(ref) 535 536 if visibility in [:public, :hidden] do 537 final_url(ref, config) 538 else 539 original_text = "#{string}/#{arity}" 540 Autolink.maybe_warn(ref, config, visibility, %{original_text: original_text}) 541 nil 542 end 543 end 544 545 if url do 546 ~s|<a href="#{url}">#{string}</a>(| 547 else 548 string <> "(" 549 end 550 end) 551 end 552 553 defp put(items) do 554 Process.put({__MODULE__, :stack}, items) 555 end 556 557 defp pop() do 558 [head | tail] = Process.get({__MODULE__, :stack}) 559 put(tail) 560 head 561 end 562 563 defp pp(name) when is_atom(name) do 564 :io_lib.format("~p", [name]) |> IO.iodata_to_binary() 565 end 566 567 defp pp({module, name}) when is_atom(module) and is_atom(name) do 568 :io_lib.format("~p:~p", [module, name]) |> IO.iodata_to_binary() 569 end 570 571 defp format_spec(ast) do 572 {:attribute, _, type, _} = ast 573 574 # `-type ` => 6 575 offset = byte_size(Atom.to_string(type)) + 2 576 577 options = [linewidth: 98 + offset] 578 :erl_pp.attribute(ast, options) |> IO.iodata_to_binary() |> trim_offset(offset) 579 end 580 581 ## Helpers 582 583 defp module_type(module) do 584 cond do 585 function_exported?(module, :behaviour_info, 1) -> 586 :behaviour 587 588 true -> 589 :module 590 end 591 end 592 593 # `-type t() :: atom()` becomes `t() :: atom().` 594 defp trim_offset(binary, offset) do 595 binary 596 |> String.trim() 597 |> String.split("\n") 598 |> Enum.map(fn line -> 599 binary_part(line, offset, byte_size(line) - offset) 600 end) 601 |> Enum.join("\n") 602 end 603 604 defp anno_line(line) when is_integer(line), do: abs(line) 605 defp anno_line(anno), do: anno |> :erl_anno.line() |> abs() 606 end