Erl_pp:form/1 doesn't print binary strings in -doc attributes

I have a module where the -doc attribute is a binary/utf8 string.

When compiled to abstract form and then back to erlang with erl_pp:form/1 it is printed as an binary integer list and not as a string.
If I use a “simple” string it works fine.

-module(test).
-moduledoc("This is a module documentation").
-export([test0/0, test1/0]).

-doc(<<"Just return the integer 10"/utf8>>).
-spec test0() -> integer().
test0() ->
    10.

-doc("Just return the integer 10").
test1() ->
    10.

Converting forth and back:

2> {ok,[], Term} = compile:file(test,[binary, to_abstr, debug_info]),
    Bin = term_to_binary(Term),
    %% file:write_file("test.abstr", Bin),
    PrettyModule = lists:map(fun(A) -> erl_pp:form(A) end, binary_to_term(Bin)),
    io:format("~ts~n", [PrettyModule]).
-file("test.erl", 1).
-module(test).
-moduledoc("This is a module documentation").
-export([test0/0,test1/0]).
-doc(<<74,117,115,116,32,114,101,116,117,114,110,32,116,
       104,101,32,105,110,116,101,103,101,114,32,49,48>>).
-spec test0() -> integer().
test0() ->
    10.
-doc("Just return the integer 10").
test1() ->
    10.

Is this the expected output or would it be possible to print the binary as a string?

It happens because erl_syntax:abstract/1 (which erl_pp uses for this) does not do any printable heuristic for binaries, while the parser keeps that information. For example:

1> erl_syntax:abstract("abc").
{tree,string,{attr,0,[],none},"abc"}
2> erl_syntax:abstract(<<"abc">>).
{tree,binary,
      {attr,0,[],none},
      [{tree,binary_field,
             {attr,0,[],none},
             {binary_field,{tree,integer,{attr,0,[],none},97},[]}},
       {tree,binary_field,
             {attr,0,[],none},
             {binary_field,{tree,integer,{attr,0,[],none},98},[]}},
       {tree,binary_field,
             {attr,0,[],none},
             {binary_field,{tree,integer,{attr,0,[],none},99},[]}}]}
3> erl_parse:parse_exprs(element(2,erl_scan:string(~s'<<"abc">>.', 0))).
{ok,[{bin,0,
          [{bin_element,0,{string,0,"abc"},default,default}]}]}

The best solution to this would probably be to teach erl_syntax to create a binary similar to erl_parse when the string is printable. The code doing this is located here. A PR would be welcome.

erl_pp seems to use erl_parse:abstract/1 and not erl_syntax dito, even though this also misses check for printable binaries.
For some reason erl_parse is included in the .gitignore so I cannot get a proper diff, but if I change the abstract/3clause for bitstring to:

abstract(B, A, E) when is_bitstring(B) ->
    L = bitstring_to_list(B),
    case abstract_list(L, [], A, E) of
        {string, _, _} = S ->
            {bin, A, [{bin_element, 0, S, default, default}]};
        _ ->
            {bin, A, [abstract_byte(Byte, A) || Byte <- L]}
    end;

it works fine and I get back:
-doc(<<"Just return the integer 10">>).
UTF8 characters are “lost” (ä → ä) though and I am not sure what happens now with bit strings that are not byte-aligned.

Yes, erl_parse is the one not erl_syntax. erl_parse.erl is generated from erl_parse.yrl so that is why it is ignored. I spent some time on this and came up with the patch below:

diff --git a/lib/stdlib/src/erl_parse.yrl b/lib/stdlib/src/erl_parse.yrl
index 9568548462..275bfbe798 100644
--- a/lib/stdlib/src/erl_parse.yrl
+++ b/lib/stdlib/src/erl_parse.yrl
@@ -2015,11 +2015,12 @@ annotation contains the location given by option `location` or by option `line`.
 Option `location` overrides option `line`. If neither option `location` nor
 option `line` is given, `0` is used as location.
 
-Option `Encoding` is used for selecting which integer lists to be considered as
-strings. The default is to use the encoding returned by function
-`epp:default_encoding/0`. Value `none` means that no integer lists are
-considered as strings. `encoding_func()` is called with one integer of a list at
-a time; if it returns `true` for every integer, the list is considered a string.
+Option `Encoding` is used for selecting which integer lists or binaries to
+be considered as strings. The default is to use the encoding returned by function
+`epp:default_encoding/0`. Value `none` means that nothing is considered a string.
+`encoding_func()` is called with one character of a list or binary at
+a time; if it returns `true` for every character, the list or binary is considered
+a string. For binaries it is the decoded utf-8 character that is passed to `encoding_func()`. If the binary is not valid utf-8, it is not considered a string.
 """.
 -doc(#{since => <<"OTP R16B01">>}).
 -spec abstract(Data, Options) -> AbsTerm when
@@ -2049,14 +2050,9 @@ abstract(T, Location) ->
     Anno = erl_anno:new(Location),
     abstract(T, Anno, enc_func(epp:default_encoding())).
 
--define(UNICODE(C),
-         (C < 16#D800 orelse
-          C > 16#DFFF andalso C < 16#FFFE orelse
-          C > 16#FFFF andalso C =< 16#10FFFF)).
-
-enc_func(latin1) -> fun(C) -> C < 256 end;
-enc_func(unicode) -> fun(C) -> ?UNICODE(C) end;
-enc_func(utf8) -> fun(C) -> ?UNICODE(C) end;
+enc_func(latin1) -> fun(C) -> io_lib:printable_latin1_list([C]) end;
+enc_func(unicode) -> fun(C) -> io_lib:printable_unicode_list([C]) end;
+enc_func(utf8) -> enc_func(unicode);
 enc_func(none) -> none;
 enc_func(Fun) when is_function(Fun, 1) -> Fun;
 enc_func(Term) -> erlang:error({badarg, Term}).
@@ -2065,6 +2061,23 @@ abstract(T, A, _E) when is_integer(T) -> {integer,A,T};
 abstract(T, A, _E) when is_float(T) -> {float,A,T};
 abstract(T, A, _E) when is_atom(T) -> {atom,A,T};
 abstract([], A, _E) -> {nil,A};
+abstract(B, A, E) when is_binary(B), is_function(E) ->
+    maybe
+        List = unicode:characters_to_list(B, utf8),
+        true ?= is_list(List),
+        {string, _, String} ?= abstract_list(List, [], A, E),
+        Encoding =
+            case is_ascii_string(String) of
+                true ->
+                    default;
+                false ->
+                    [utf8]
+            end,
+        {bin, A, [{bin_element, A, {string, A, String}, default, Encoding}]}
+    else
+        _ ->
+            {bin, A, [abstract_byte(Byte, A) || Byte <- binary_to_list(B)]}
+    end;
 abstract(B, A, _E) when is_bitstring(B) ->
     {bin, A, [abstract_byte(Byte, A) || Byte <- bitstring_to_list(B)]};
 abstract([H|T], A, none=E) ->
@@ -2088,6 +2101,9 @@ abstract(Fun, A, E) when is_function(Fun) ->
                         abstract(Arity, A, E)}}
     end.
 
+is_ascii_string(L) ->
+    lists:all(fun(C) -> C >= 0 andalso C < 128 end, L).
+
 abstract_list([H|T], String, A, E) ->
     case is_integer(H) andalso H >= 0 andalso E(H) of
         true ->

It seems to solve the problem you listed here, but if you could also run it on the original problem and see if it is solved there as well that would be great. I’ll create a PR with the fixed as soon as I have added tests.

The PR: erl_parse: Fix abstract/1 to handle unicode binary strings by garazdawi · Pull Request #11536 · erlang/otp · GitHub

Thanks Lukas! Looks really great.
The problem surfaced when the gleamlins decided to compile (transpile?) their code to abstract form instead of Erlang source but “some folks” :slightly_smiling_face: still liked to see how the erlang code would look like.
I have checked some .abstr files generated from gleam source and the -doc attributes look fine with /utf8 added for those that contain unicode chars.
Line alignment is there too:

-doc(<<" Set the name of an event which clients use to route it to a li"
       "stener.">>).