Support multi-level namespace structs

Fixes https://github.com/bazelbuild/skydoc/issues/165.

PiperOrigin-RevId: 258559367
diff --git a/src/main/java/com/google/devtools/build/skydoc/SkydocMain.java b/src/main/java/com/google/devtools/build/skydoc/SkydocMain.java
index b1ac195..b5e1df1 100644
--- a/src/main/java/com/google/devtools/build/skydoc/SkydocMain.java
+++ b/src/main/java/com/google/devtools/build/skydoc/SkydocMain.java
@@ -389,13 +389,9 @@
         userDefinedFunctionMap.put(envEntry.getKey(), userDefinedFunction);
       }
       if (envEntry.getValue() instanceof FakeStructApi) {
-        FakeStructApi struct = (FakeStructApi) envEntry.getValue();
-        for (String field : struct.getFieldNames()) {
-          if (struct.getValue(field) instanceof UserDefinedFunction) {
-            UserDefinedFunction userDefinedFunction = (UserDefinedFunction) struct.getValue(field);
-            userDefinedFunctionMap.put(envEntry.getKey() + "." + field, userDefinedFunction);
-          }
-        }
+        String namespaceName = envEntry.getKey();
+        FakeStructApi namespace = (FakeStructApi) envEntry.getValue();
+        putStructFields(namespaceName, namespace, userDefinedFunctionMap);
       }
     }
 
@@ -403,6 +399,31 @@
   }
 
   /**
+   * Recursively adds functions defined in {@code namespace}, and in its nested namespaces, to
+   * {@code userDefinedFunctionMap}.
+   *
+   * <p>Each entry's key is the fully qualified function name, e.g. {@code
+   * "outernamespace.innernamespace.func"}. {@code namespaceName} is the fully qualified name of
+   * {@code namespace} itself.
+   */
+  private static void putStructFields(
+      String namespaceName,
+      FakeStructApi namespace,
+      ImmutableMap.Builder<String, UserDefinedFunction> userDefinedFunctionMap)
+      throws EvalException {
+    for (String field : namespace.getFieldNames()) {
+      String qualifiedFieldName = namespaceName + "." + field;
+      if (namespace.getValue(field) instanceof UserDefinedFunction) {
+        UserDefinedFunction userDefinedFunction = (UserDefinedFunction) namespace.getValue(field);
+        userDefinedFunctionMap.put(qualifiedFieldName, userDefinedFunction);
+      } else if (namespace.getValue(field) instanceof FakeStructApi) {
+        FakeStructApi innerNamespace = (FakeStructApi) namespace.getValue(field);
+        putStructFields(qualifiedFieldName, innerNamespace, userDefinedFunctionMap);
+      }
+    }
+  }
+
+  /**
    * Recursively evaluates/interprets the skylark file at a given path and its transitive skylark
    * dependencies using a fake build API and collects information about all rule definitions made in
    * those files.
diff --git a/src/test/java/com/google/devtools/build/skydoc/BUILD b/src/test/java/com/google/devtools/build/skydoc/BUILD
index 0e9fdb9..050190d 100644
--- a/src/test/java/com/google/devtools/build/skydoc/BUILD
+++ b/src/test/java/com/google/devtools/build/skydoc/BUILD
@@ -182,19 +182,37 @@
 )
 
 skydoc_test(
-    name = "module_test",
-    golden_file = "testdata/module_test/golden.txt",
-    input_file = "testdata/module_test/input.bzl",
+    name = "namespace_test",
+    golden_file = "testdata/namespace_test/golden.txt",
+    input_file = "testdata/namespace_test/input.bzl",
     skydoc = "//src/main/java/com/google/devtools/build/skydoc",
 )
 
 skydoc_test(
-    name = "module_test_with_whitelist",
-    golden_file = "testdata/module_test/golden.txt",
-    input_file = "testdata/module_test/input.bzl",
+    name = "namespace_test_with_whitelist",
+    golden_file = "testdata/namespace_test/golden.txt",
+    input_file = "testdata/namespace_test/input.bzl",
     skydoc = "//src/main/java/com/google/devtools/build/skydoc",
     whitelisted_symbols = [
-        "my_module",
+        "my_namespace",
+    ],
+)
+
+skydoc_test(
+    name = "multi_level_namespace_test",
+    golden_file = "testdata/multi_level_namespace_test/golden.txt",
+    input_file = "testdata/multi_level_namespace_test/input.bzl",
+    skydoc = "//src/main/java/com/google/devtools/build/skydoc",
+)
+
+skydoc_test(
+    name = "multi_level_namespace_test_with_whitelist",
+    golden_file = "testdata/multi_level_namespace_test_with_whitelist/golden.txt",
+    input_file = "testdata/multi_level_namespace_test_with_whitelist/input.bzl",
+    skydoc = "//src/main/java/com/google/devtools/build/skydoc",
+    whitelisted_symbols = [
+        "my_namespace",
+        "other_namespace.foo.nothing",
     ],
 )
 
diff --git a/src/test/java/com/google/devtools/build/skydoc/testdata/multi_level_namespace_test/golden.txt b/src/test/java/com/google/devtools/build/skydoc/testdata/multi_level_namespace_test/golden.txt
new file mode 100644
index 0000000..58f662a
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/skydoc/testdata/multi_level_namespace_test/golden.txt
@@ -0,0 +1,119 @@
+<!-- Generated with Stardoc: http://skydoc.bazel.build -->
+
+<a name="#my_namespace.min"></a>
+
+## my_namespace.min
+
+<pre>
+my_namespace.min(<a href="#my_namespace.min-integers">integers</a>)
+</pre>
+
+Returns the minimum of given elements.
+
+### Parameters
+
+<table class="params-table">
+  <colgroup>
+    <col class="col-param" />
+    <col class="col-description" />
+  </colgroup>
+  <tbody>
+    <tr id="my_namespace.min-integers">
+      <td><code>integers</code></td>
+      <td>
+        required.
+        <p>
+          A list of integers. Must not be empty.
+        </p>
+      </td>
+    </tr>
+  </tbody>
+</table>
+
+
+<a name="#my_namespace.math.min"></a>
+
+## my_namespace.math.min
+
+<pre>
+my_namespace.math.min(<a href="#my_namespace.math.min-integers">integers</a>)
+</pre>
+
+Returns the minimum of given elements.
+
+### Parameters
+
+<table class="params-table">
+  <colgroup>
+    <col class="col-param" />
+    <col class="col-description" />
+  </colgroup>
+  <tbody>
+    <tr id="my_namespace.math.min-integers">
+      <td><code>integers</code></td>
+      <td>
+        required.
+        <p>
+          A list of integers. Must not be empty.
+        </p>
+      </td>
+    </tr>
+  </tbody>
+</table>
+
+
+<a name="#my_namespace.foo.bar.baz"></a>
+
+## my_namespace.foo.bar.baz
+
+<pre>
+my_namespace.foo.bar.baz()
+</pre>
+
+This function does nothing.
+
+
+
+<a name="#my_namespace.one.two.min"></a>
+
+## my_namespace.one.two.min
+
+<pre>
+my_namespace.one.two.min(<a href="#my_namespace.one.two.min-integers">integers</a>)
+</pre>
+
+Returns the minimum of given elements.
+
+### Parameters
+
+<table class="params-table">
+  <colgroup>
+    <col class="col-param" />
+    <col class="col-description" />
+  </colgroup>
+  <tbody>
+    <tr id="my_namespace.one.two.min-integers">
+      <td><code>integers</code></td>
+      <td>
+        required.
+        <p>
+          A list of integers. Must not be empty.
+        </p>
+      </td>
+    </tr>
+  </tbody>
+</table>
+
+
+<a name="#my_namespace.one.three.does_nothing"></a>
+
+## my_namespace.one.three.does_nothing
+
+<pre>
+my_namespace.one.three.does_nothing()
+</pre>
+
+This function does nothing.
+
+
+
diff --git a/src/test/java/com/google/devtools/build/skydoc/testdata/multi_level_namespace_test/input.bzl b/src/test/java/com/google/devtools/build/skydoc/testdata/multi_level_namespace_test/input.bzl
new file mode 100644
index 0000000..8b0b436
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/skydoc/testdata/multi_level_namespace_test/input.bzl
@@ -0,0 +1,32 @@
+"""A test that verifies documenting a multi-leveled namespace of functions."""
+
+def _min(integers):
+    """Returns the minimum of given elements.
+
+    Args:
+      integers: A list of integers. Must not be empty.
+
+    Returns:
+      The minimum integer in the given list.
+    """
+    _ignore = [integers]
+    return 42
+
+def _does_nothing():
+    """This function does nothing."""
+    pass
+
+my_namespace = struct(
+    dropped_field = "Note this field should not be documented",
+    min = _min,
+    math = struct(min = _min),
+    foo = struct(
+        bar = struct(baz = _does_nothing),
+        num = 12,
+        string = "Hello!",
+    ),
+    one = struct(
+        two = struct(min = _min),
+        three = struct(does_nothing = _does_nothing),
+    ),
+)
diff --git a/src/test/java/com/google/devtools/build/skydoc/testdata/multi_level_namespace_test_with_whitelist/golden.txt b/src/test/java/com/google/devtools/build/skydoc/testdata/multi_level_namespace_test_with_whitelist/golden.txt
new file mode 100644
index 0000000..3eaf093
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/skydoc/testdata/multi_level_namespace_test_with_whitelist/golden.txt
@@ -0,0 +1,70 @@
+<!-- Generated with Stardoc: http://skydoc.bazel.build -->
+
+<a name="#my_namespace.min"></a>
+
+## my_namespace.min
+
+<pre>
+my_namespace.min(<a href="#my_namespace.min-integers">integers</a>)
+</pre>
+
+Returns the minimum of given elements.
+
+### Parameters
+
+<table class="params-table">
+  <colgroup>
+    <col class="col-param" />
+    <col class="col-description" />
+  </colgroup>
+  <tbody>
+    <tr id="my_namespace.min-integers">
+      <td><code>integers</code></td>
+      <td>
+        required.
+      </td>
+    </tr>
+  </tbody>
+</table>
+
+
+<a name="#my_namespace.math.min"></a>
+
+## my_namespace.math.min
+
+<pre>
+my_namespace.math.min(<a href="#my_namespace.math.min-integers">integers</a>)
+</pre>
+
+Returns the minimum of given elements.
+
+### Parameters
+
+<table class="params-table">
+  <colgroup>
+    <col class="col-param" />
+    <col class="col-description" />
+  </colgroup>
+  <tbody>
+    <tr id="my_namespace.math.min-integers">
+      <td><code>integers</code></td>
+      <td>
+        required.
+      </td>
+    </tr>
+  </tbody>
+</table>
+
+
+<a name="#other_namespace.foo.nothing"></a>
+
+## other_namespace.foo.nothing
+
+<pre>
+other_namespace.foo.nothing()
+</pre>
+
+This function does nothing.
+
+
+
diff --git a/src/test/java/com/google/devtools/build/skydoc/testdata/multi_level_namespace_test_with_whitelist/input.bzl b/src/test/java/com/google/devtools/build/skydoc/testdata/multi_level_namespace_test_with_whitelist/input.bzl
new file mode 100644
index 0000000..34eb87b
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/skydoc/testdata/multi_level_namespace_test_with_whitelist/input.bzl
@@ -0,0 +1,23 @@
+"""A test that verifies documenting a multi-leveled namespace of functions with whitelist symbols.
+The whitelist symbols should cause everything in my_namespace to to be documented, but only a
+specific symbol in other_namespace to be documented."""
+
+def _min(integers):
+    """Returns the minimum of given elements."""
+    _ignore = [integers]
+    return 42
+
+def _does_nothing():
+    """This function does nothing."""
+    pass
+
+my_namespace = struct(
+    dropped_field = "Note this field should not be documented",
+    min = _min,
+    math = struct(min = _min),
+)
+
+other_namespace = struct(
+    foo = struct(nothing = _does_nothing),
+    min = _min,
+)
diff --git a/src/test/java/com/google/devtools/build/skydoc/testdata/module_test/golden.txt b/src/test/java/com/google/devtools/build/skydoc/testdata/namespace_test/golden.txt
similarity index 64%
rename from src/test/java/com/google/devtools/build/skydoc/testdata/module_test/golden.txt
rename to src/test/java/com/google/devtools/build/skydoc/testdata/namespace_test/golden.txt
index ca95f87..6fd6bdd 100644
--- a/src/test/java/com/google/devtools/build/skydoc/testdata/module_test/golden.txt
+++ b/src/test/java/com/google/devtools/build/skydoc/testdata/namespace_test/golden.txt
@@ -1,11 +1,11 @@
 <!-- Generated with Stardoc: http://skydoc.bazel.build -->
 
-<a name="#my_module.assert_non_empty"></a>
+<a name="#my_namespace.assert_non_empty"></a>
 
-## my_module.assert_non_empty
+## my_namespace.assert_non_empty
 
 <pre>
-my_module.assert_non_empty(<a href="#my_module.assert_non_empty-some_list">some_list</a>, <a href="#my_module.assert_non_empty-other_list">other_list</a>)
+my_namespace.assert_non_empty(<a href="#my_namespace.assert_non_empty-some_list">some_list</a>, <a href="#my_namespace.assert_non_empty-other_list">other_list</a>)
 </pre>
 
 Asserts the two given lists are not empty.
@@ -18,7 +18,7 @@
     <col class="col-description" />
   </colgroup>
   <tbody>
-    <tr id="my_module.assert_non_empty-some_list">
+    <tr id="my_namespace.assert_non_empty-some_list">
       <td><code>some_list</code></td>
       <td>
         required.
@@ -27,7 +27,7 @@
         </p>
       </td>
     </tr>
-    <tr id="my_module.assert_non_empty-other_list">
+    <tr id="my_namespace.assert_non_empty-other_list">
       <td><code>other_list</code></td>
       <td>
         required.
@@ -40,12 +40,12 @@
 </table>
 
 
-<a name="#my_module.min"></a>
+<a name="#my_namespace.min"></a>
 
-## my_module.min
+## my_namespace.min
 
 <pre>
-my_module.min(<a href="#my_module.min-integers">integers</a>)
+my_namespace.min(<a href="#my_namespace.min-integers">integers</a>)
 </pre>
 
 Returns the minimum of given elements.
@@ -58,7 +58,7 @@
     <col class="col-description" />
   </colgroup>
   <tbody>
-    <tr id="my_module.min-integers">
+    <tr id="my_namespace.min-integers">
       <td><code>integers</code></td>
       <td>
         required.
@@ -71,12 +71,12 @@
 </table>
 
 
-<a name="#my_module.join_strings"></a>
+<a name="#my_namespace.join_strings"></a>
 
-## my_module.join_strings
+## my_namespace.join_strings
 
 <pre>
-my_module.join_strings(<a href="#my_module.join_strings-strings">strings</a>, <a href="#my_module.join_strings-delimiter">delimiter</a>)
+my_namespace.join_strings(<a href="#my_namespace.join_strings-strings">strings</a>, <a href="#my_namespace.join_strings-delimiter">delimiter</a>)
 </pre>
 
 Joins the given strings with a delimiter.
@@ -89,7 +89,7 @@
     <col class="col-description" />
   </colgroup>
   <tbody>
-    <tr id="my_module.join_strings-strings">
+    <tr id="my_namespace.join_strings-strings">
       <td><code>strings</code></td>
       <td>
         required.
@@ -98,7 +98,7 @@
         </p>
       </td>
     </tr>
-    <tr id="my_module.join_strings-delimiter">
+    <tr id="my_namespace.join_strings-delimiter">
       <td><code>delimiter</code></td>
       <td>
         optional. default is <code>", "</code>
diff --git a/src/test/java/com/google/devtools/build/skydoc/testdata/module_test/input.bzl b/src/test/java/com/google/devtools/build/skydoc/testdata/namespace_test/input.bzl
similarity index 91%
rename from src/test/java/com/google/devtools/build/skydoc/testdata/module_test/input.bzl
rename to src/test/java/com/google/devtools/build/skydoc/testdata/namespace_test/input.bzl
index d1b04b3..69cad64 100644
--- a/src/test/java/com/google/devtools/build/skydoc/testdata/module_test/input.bzl
+++ b/src/test/java/com/google/devtools/build/skydoc/testdata/namespace_test/input.bzl
@@ -1,4 +1,4 @@
-"""A test that verifies documenting a module of functions."""
+"""A test that verifies documenting a namespace of functions."""
 
 def _min(integers):
     """Returns the minimum of given elements.
@@ -35,7 +35,7 @@
     _ignore = [strings, delimiter]
     return ""
 
-my_module = struct(
+my_namespace = struct(
     dropped_field = "Note this field should not be documented",
     assert_non_empty = _assert_non_empty,
     min = _min,