From fe5ddbeb421fa7958575f2cad0313f20a38984e9 Mon Sep 17 00:00:00 2001
From: crowlbot <280062030+crowlbot@users.noreply.github.com>
Date: Fri, 5 Jun 2026 03:44:22 +0000
Subject: [PATCH] fix(html): render @param docs for rest parameters
The rendered name of a rest parameter carries a `...` prefix (e.g.
`...rest`), but JSDoc `@param` tag names are bare identifiers. The
parameter-doc lookup keyed off the rendered name, so rest parameters
never matched their `@param` tag and their documentation was dropped.
Strip the leading `...` when looking up the doc so rest parameters
render their description, default and optional flag like any other
parameter.
---
src/html/symbols/function.rs | 8 ++-
tests/html_test.rs | 58 +++++++++++++++++++
...ml_test__diff_comprehensive_diff_only.snap | 2 +-
.../html_test__diff_comprehensive_full.snap | 2 +-
4 files changed, 66 insertions(+), 4 deletions(-)
diff --git a/src/html/symbols/function.rs b/src/html/symbols/function.rs
index 00c00e9c8..eda9ca8e8 100644
--- a/src/html/symbols/function.rs
+++ b/src/html/symbols/function.rs
@@ -297,13 +297,17 @@ fn render_single_function(
.enumerate()
.map(|(i, param)| {
let (name, str_name) = crate::html::parameters::param_name(param, i);
+ // `@param` tag names are bare identifiers, but the rendered name of a
+ // rest parameter carries a `...` prefix (e.g. `...rest`). Strip it so the
+ // doc lookup matches the tag (see issue #574).
+ let lookup_name = str_name.trim_start_matches('.');
let id = IdBuilder::new_with_parent(ctx, &overload_id)
.kind(IdKind::Parameter)
.name(&str_name)
.build();
let (mut default, optional) = if let Some((_doc, optional, default)) =
- param_docs.get(name.as_str())
+ param_docs.get(lookup_name)
{
((**default).to_owned(), *optional)
} else {
@@ -342,7 +346,7 @@ fn render_single_function(
};
let param_doc = param_docs
- .get(name.as_str())
+ .get(lookup_name)
.and_then(|(doc, _, _)| doc.as_deref());
let (diff_status, old_content) =
diff --git a/tests/html_test.rs b/tests/html_test.rs
index 0b90708bf..893a36394 100644
--- a/tests/html_test.rs
+++ b/tests/html_test.rs
@@ -761,6 +761,64 @@ export class Foo {
);
}
+// Regression test for https://github.com/denoland/deno_doc/issues/574:
+// `@param` documentation must render for rest/spread parameters. The rendered
+// parameter name carries a `...` prefix, but the JSDoc `@param` tag name does
+// not, so the doc lookup has to match on the bare identifier.
+#[tokio::test]
+async fn html_rest_param_jsdoc() {
+ let source = r#"
+/**
+ * Sums numbers.
+ *
+ * @param first the leading number
+ * @param rest the trailing numbers
+ */
+export function sum(first: number, ...rest: number[]): number {
+ return first + rest.reduce((a, b) => a + b, 0);
+}
+"#;
+
+ let ctx = GenerateCtx::create_basic(
+ GenerateOptions {
+ package_name: None,
+ main_entrypoint: None,
+ href_resolver: Arc::new(EmptyResolver),
+ usage_composer: Some(Arc::new(EmptyResolver)),
+ rewrite_map: None,
+ category_docs: None,
+ disable_search: false,
+ symbol_redirect_map: None,
+ default_symbol_map: None,
+ markdown_renderer: comrak::create_renderer(None, None, None),
+ markdown_stripper: Arc::new(comrak::strip),
+ head_inject: None,
+ id_prefix: None,
+ diff_only: false,
+ },
+ parse_source(source).await,
+ None,
+ )
+ .unwrap();
+
+ let files = generate(ctx).unwrap();
+
+ let sum_page = files
+ .get("./~/sum.html")
+ .expect("function symbol page should be generated");
+
+ // The non-rest parameter has always rendered its doc.
+ assert!(
+ sum_page.contains("the leading number"),
+ "expected the first parameter's @param doc to render"
+ );
+ // The rest parameter's @param doc must render too (the bug in #574).
+ assert!(
+ sum_page.contains("the trailing numbers"),
+ "expected the rest parameter's @param doc to render"
+ );
+}
+
#[tokio::test]
async fn diff_kind_change() {
let test_dir = std::env::current_dir()
diff --git a/tests/snapshots/html_test__diff_comprehensive_diff_only.snap b/tests/snapshots/html_test__diff_comprehensive_diff_only.snap
index 1c9788f43..c2c6c3a95 100644
--- a/tests/snapshots/html_test__diff_comprehensive_diff_only.snap
+++ b/tests/snapshots/html_test__diff_comprehensive_diff_only.snap
@@ -249,7 +249,7 @@ expression: pages
],
[
"./~/createPipeline.json",
- "{\"kind\":\"SymbolPageCtx\",\"html_head_ctx\":{\"title\":\"createPipeline - default - documentation\",\"current_file\":\".\",\"stylesheet_url\":\"../styles.css\",\"page_stylesheet_url\":\"../page.css\",\"reset_stylesheet_url\":\"../reset.css\",\"url_search_index\":\"../search_index.js\",\"script_js\":\"../script.js\",\"fuse_js\":\"../fuse.js\",\"search_js\":\"../search.js\",\"darkmode_toggle_js\":\"../darkmode_toggle.js\",\"head_inject\":null,\"disable_search\":false},\"symbol_group_ctx\":{\"name\":\"createPipeline\",\"symbols\":[{\"kind\":{\"kind\":\"Function\",\"char\":\"f\",\"title\":\"Function\",\"title_lowercase\":\"function\",\"title_plural\":\"Functions\"},\"usage\":null,\"tags\":[],\"subtitle\":null,\"content\":[{\"kind\":\"function\",\"value\":{\"functions\":[{\"anchor\":{\"id\":\"function_createpipeline_0\"},\"name\":\"createPipeline\",\"summary\":\"(...middlewares: Middleware[]): Middleware\",\"deprecated\":null,\"content\":{\"id\":\"\",\"docs\":\"