Repository: gleam-lang/gleam (Rust)

Issue:
Mark internal functions as hidden in the generated Erlang code
Browsing the Erlang documentation I discovered there's a way to mark exported functions as hidden and not part of the public API, that's the same as `@internal`! It would be really nice if we did that for internal functions when generating code for the Erlang target.

The way to do it is by adding a `-doc false` directive  <https://www.erlang.org/doc/system/documentation.html#what-is-visible-versus-hidden>:
```erl
-export([internal_function/0]).

-doc false.
internal_function() -> wibble.
```

I think the `doc` directive was added to OPT27, since we support both 26 and 27 we'll have to implement it like this:
```erl
-if(?OTP_RELEASE >= 27).
-define(MODULEDOC(Str), -moduledoc(Str)).
-define(DOC(Str), -doc(Str)).
-else.
-define(MODULEDOC(Str), -compile([])).
-define(DOC(Str), -compile([])).
-endif.
```
But it's no big deal since we're doing the same to add doc comments to the generated code!


Discussion:
Sounds good to me!
Ok, I'm working on it!



Patch:
diff --git a/compiler-core/src/erlang.rs b/compiler-core/src/erlang.rs
index 2a93565de8f..20a777c29be 100644
--- a/compiler-core/src/erlang.rs
+++ b/compiler-core/src/erlang.rs
@@ -222,19 +222,31 @@ fn module_document<'a>(
     let mut needs_function_docs = false;
     let mut statements = Vec::with_capacity(module.definitions.len());
     for definition in module.definitions.iter() {
-        if let Some((statement_document, env)) =
-            module_statement(definition, &module.name, line_numbers, &src_path)
-        {
+        if let Some((statement_document, env)) = module_statement(
+            definition,
+            &module.name,
+            module.type_info.is_internal,
+            line_numbers,
+            &src_path,
+        ) {
             needs_function_docs = needs_function_docs || env.needs_function_docs;
             statements.push(statement_document);
         }
     }
 
+    let module_doc = if module.type_info.is_internal {
+        Some(hidden_module_doc().append(lines(2)))
+    } else if module.documentation.is_empty() {
+        None
+    } else {
+        Some(module_doc(&module.documentation).append(lines(2)))
+    };
+
     // We're going to need the documentation directives if any of the module's
     // functions need it, or if the module has a module comment that we want to
-    // include in the generated Erlang source.
-    let needs_function_docs = needs_function_docs || !module.documentation.is_empty();
-    let documentation_directive = if needs_function_docs {
+    // include in the generated Erlang source, or if the module is internal.
+    let needs_doc_directive = needs_function_docs || module_doc.is_some();
+    let documentation_directive = if needs_doc_directive {
         "-if(?OTP_RELEASE >= 27).
 -define(MODULEDOC(Str), -moduledoc(Str)).
 -define(DOC(Str), -doc(Str)).
@@ -248,12 +260,6 @@ fn module_document<'a>(
         nil()
     };
 
-    let module_doc = if module.documentation.is_empty() {
-        nil()
-    } else {
-        doc_attribute(DocCommentKind::Module, &module.documentation).append(lines(2))
-    };
-
     let module = docvec![
         header,
         "-compile([no_auto_import, nowarn_unused_vars, nowarn_unused_function, nowarn_nomatch]).",
@@ -386,6 +392,7 @@ fn register_imports(
 fn module_statement<'a>(
     statement: &'a TypedDefinition,
     module: &'a str,
+    is_internal_module: bool,
     line_numbers: &'a LineNumbers,
     src_path: &EcoString,
 ) -> Option<(Document<'a>, Env<'a>)> {
@@ -395,15 +402,20 @@ fn module_statement<'a>(
         | Definition::Import(Import { .. })
         | Definition::ModuleConstant(ModuleConstant { .. }) => None,
 
-        Definition::Function(function) => {
-            module_function(function, module, line_numbers, src_path.clone())
-        }
+        Definition::Function(function) => module_function(
+            function,
+            module,
+            is_internal_module,
+            line_numbers,
+            src_path.clone(),
+        ),
     }
 }
 
 fn module_function<'a>(
     function: &'a TypedFunction,
     module: &'a str,
+    is_internal_module: bool,
     line_numbers: &'a LineNumbers,
     src_path: EcoString,
 ) -> Option<(Document<'a>, Env<'a>)> {
@@ -459,17 +471,20 @@ fn module_function<'a>(
         .unwrap_or_else(|| statement_sequence(&function.body, &mut env));
 
     let attributes = file_attribute;
-    let attributes = if let Some((_, documentation)) = &function.documentation {
+    let attributes = if is_internal_module || function.publicity.is_internal() {
+        // If a function is marked as internal or comes from an internal module
+        // we want to hide its documentation in the Erlang shell!
+        // So the doc directive will look like this: `-doc(false).`
+        env.needs_function_docs = true;
+        docvec![attributes, line(), hidden_function_doc()]
+    } else if let Some((_, documentation)) = &function.documentation {
         env.needs_function_docs = true;
         let doc_lines = documentation
             .trim_end()
             .split('\n')
             .map(EcoString::from)
             .collect_vec();
-
-        attributes
-            .append(line())
-            .append(doc_attribute(DocCommentKind::Function, &doc_lines))
+        docvec![attributes, line(), function_doc(&doc_lines)]
     } else {
         attributes
     };
@@ -504,24 +519,51 @@ enum DocCommentKind {
     Function,
 }
 
-fn doc_attribute<'a>(kind: DocCommentKind, doc_lines: &[EcoString]) -> Document<'a> {
+enum DocCommentContent<'a> {
+    String(&'a Vec<EcoString>),
+    False,
+}
+
+fn hidden_module_doc<'a>() -> Document<'a> {
+    doc_attribute(DocCommentKind::Module, DocCommentContent::False)
+}
+
+fn module_doc<'a>(content: &Vec<EcoString>) -> Document<'a> {
+    doc_attribute(DocCommentKind::Module, DocCommentContent::String(content))
+}
+
+fn hidden_function_doc<'a>() -> Document<'a> {
+    doc_attribute(DocCommentKind::Function, DocCommentContent::False)
+}
+
+fn function_doc<'a>(content: &Vec<EcoString>) -> Document<'a> {
+    doc_attribute(DocCommentKind::Function, DocCommentContent::String(content))
+}
+
+fn doc_attribute<'a>(kind: DocCommentKind, content: DocCommentContent<'_>) -> Document<'a> {
     let prefix = match kind {
         DocCommentKind::Module => "?MODULEDOC",
         DocCommentKind::Function => "?DOC",
     };
-    let is_multiline_doc_comment = doc_lines.len() > 1;
-    let doc_lines = join(
-        doc_lines.iter().map(|line| {
-            let line = line.replace("\\", "\\\\").replace("\"", "\\\"");
-            docvec!["\"", line, "\\n\""]
-        }),
-        line(),
-    );
-    if is_multiline_doc_comment {
-        let nested_documentation = docvec![line(), doc_lines].nest(INDENT);
-        docvec![prefix, "(", nested_documentation, line(), ")."]
-    } else {
-        docvec![prefix, "(", doc_lines, ")."]
+
+    match content {
+        DocCommentContent::False => prefix.to_doc().append("(false)."),
+        DocCommentContent::String(doc_lines) => {
+            let is_multiline_doc_comment = doc_lines.len() > 1;
+            let doc_lines = join(
+                doc_lines.iter().map(|line| {
+                    let line = line.replace("\\", "\\\\").replace("\"", "\\\"");
+                    docvec!["\"", line, "\\n\""]
+                }),
+                line(),
+            );
+            if is_multiline_doc_comment {
+                let nested_documentation = docvec![line(), doc_lines].nest(INDENT);
+                docvec![prefix, "(", nested_documentation, line(), ")."]
+            } else {
+                docvec![prefix, "(", doc_lines, ")."]
+            }
+        }
     }
 }
 


Test patch:
diff --git a/compiler-core/src/erlang/tests/documentation.rs b/compiler-core/src/erlang/tests/documentation.rs
index 66772fc5b70..9288e0c337a 100644
--- a/compiler-core/src/erlang/tests/documentation.rs
+++ b/compiler-core/src/erlang/tests/documentation.rs
@@ -79,3 +79,13 @@ fn backslashes_are_escaped_in_module_comment() {
 pub fn main() { 1 }"#
     );
 }
+
+#[test]
+fn internal_function_has_no_documentation() {
+    assert_erl!(
+        r#"
+/// hidden!
+@internal
+pub fn main() { 1 }"#
+    );
+}
diff --git a/compiler-core/src/erlang/tests/snapshots/gleam_core__erlang__tests__documentation__internal_function_has_no_documentation.snap b/compiler-core/src/erlang/tests/snapshots/gleam_core__erlang__tests__documentation__internal_function_has_no_documentation.snap
new file mode 100644
index 00000000000..d73e21a4172
--- /dev/null
+++ b/compiler-core/src/erlang/tests/snapshots/gleam_core__erlang__tests__documentation__internal_function_has_no_documentation.snap
@@ -0,0 +1,29 @@
+---
+source: compiler-core/src/erlang/tests/documentation.rs
+expression: "\n/// hidden!\n@internal\npub fn main() { 1 }"
+---
+----- SOURCE CODE
+
+/// hidden!
+@internal
+pub fn main() { 1 }
+
+----- COMPILED ERLANG
+-module(my@mod).
+-compile([no_auto_import, nowarn_unused_vars, nowarn_unused_function, nowarn_nomatch]).
+
+-export([main/0]).
+
+-if(?OTP_RELEASE >= 27).
+-define(MODULEDOC(Str), -moduledoc(Str)).
+-define(DOC(Str), -doc(Str)).
+-else.
+-define(MODULEDOC(Str), -compile([])).
+-define(DOC(Str), -compile([])).
+-endif.
+
+-file("/root/project/test/my/mod.gleam", 4).
+?DOC(false).
+-spec main() -> integer().
+main() ->
+    1.