fix(go): a type declared on its own keeps its doc comment (#2438)

tree-sitter-go puts the comment above `type Foo struct{}` before the
`type_declaration`, but the node is made from the spec inside it, so both
extractors found no docstring for an ungrouped struct, interface, defined
type or alias. A type inside a `type ( ... )` group kept its own comment,
which sits beside it in the parentheses.

Go's docstring is now read from the declaration when the declaration
holds one spec and nothing precedes that spec inside it. The leading
comment of a group with several members stays the group's.

Reading from the declaration exposed comments that trail the line above:
`const _ = proto.GoGoProtoPackageIsVersion3 // please upgrade the proto
package` would have become `type MetricType int32`'s doc. For every Go
declaration, a comment that begins after code on its line, and one that
begins on the line such a comment ends, is now left out, as go/parser
reads them. Seven function docstrings on etcd and prometheus lose one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-07 16:07:02 +00:00
committed by GitHub
parent e47cb258c1
commit b635dd467f
9 changed files with 396 additions and 19 deletions
+74 -6
View File
@@ -142,6 +142,24 @@ pub fn preceding_docstring_stepping_over(
node: Node,
src: &str,
step_over: &[&str],
) -> Option<String> {
preceding_docstring_with(node, src, step_over, false)
}
/// getPrecedingDocstring's `skipTrailing` (Go) — the comments the run opens
/// with that belong to the line above are left out: one written after code on
/// its line (`const n = 4 // four.`), and any that begins on the line such a
/// comment ends. Go reads them as that line's comment, never the next
/// declaration's doc.
pub fn preceding_docstring_skipping_trailing(node: Node, src: &str) -> Option<String> {
preceding_docstring_with(node, src, &[], true)
}
fn preceding_docstring_with(
node: Node,
src: &str,
step_over: &[&str],
skip_trailing: bool,
) -> Option<String> {
let mut anchor = node;
while let Some(parent) = anchor.parent() {
@@ -152,11 +170,11 @@ pub fn preceding_docstring_stepping_over(
}
}
let mut comments: Vec<&str> = Vec::new();
let mut comments: Vec<Node> = Vec::new();
let mut sibling = anchor.prev_named_sibling();
while let Some(s) = sibling {
if is_comment(s.kind()) {
comments.push(&src[s.byte_range()]);
comments.push(s);
sibling = s.prev_named_sibling();
} else if step_over.contains(&s.kind()) {
sibling = s.prev_named_sibling();
@@ -164,14 +182,31 @@ pub fn preceding_docstring_stepping_over(
break;
}
}
if comments.is_empty() {
comments.reverse(); // collected nearest-first; TS unshifts to keep source order
let mut first = 0;
if skip_trailing {
let mut end_row = 0;
for c in &comments {
let trails = if first == 0 {
follows_code_on_its_line(*c, src)
} else {
c.start_position().row == end_row
};
if !trails {
break;
}
end_row = c.end_position().row;
first += 1;
}
}
if first == comments.len() {
return None;
}
comments.reverse(); // collected nearest-first; TS unshifts to keep source order
Some(
comments
comments[first..]
.iter()
.map(|c| clean_comment_markers(c))
.map(|c| clean_comment_markers(&src[c.byte_range()]))
.collect::<Vec<_>>()
.join("\n")
.trim()
@@ -179,6 +214,13 @@ pub fn preceding_docstring_stepping_over(
)
}
/// followsCodeOnItsLine (tree-sitter-helpers.ts) — whether code comes before
/// `node` on the line it starts on.
fn follows_code_on_its_line(node: Node, src: &str) -> bool {
let before = src[..node.start_byte()].trim_end_matches([' ', '\t']);
!before.is_empty() && !before.ends_with(['\n', '\r'])
}
#[cfg(test)]
mod tests {
use super::*;
@@ -231,4 +273,30 @@ mod tests {
Some("a\nb")
);
}
/// Skipping trailing comments leaves out one written after code on its
/// line and one beginning on the line it ends; a comment on a line of its
/// own still opens the run. Without it, nothing is left out.
#[test]
fn skips_the_comments_that_trail_code() {
let grammar = crate::langs::grammar_for("go").expect("go grammar");
let mut parser = tree_sitter::Parser::new();
parser.set_language(&grammar).unwrap();
let src = "package p\n\nconst n = 4 /* a */ // b\n// c\nfunc f() {}\n\nvar m = 1 // d\nfunc g() {}\n";
let tree = parser.parse(src, None).unwrap();
let root = tree.root_node();
let func = |name: &str| {
(0..root.named_child_count())
.filter_map(|i| root.named_child(i))
.find(|n| {
n.kind() == "function_declaration"
&& n.child_by_field_name("name").map(|id| &src[id.byte_range()]) == Some(name)
})
.expect(name)
};
assert_eq!(preceding_docstring(func("f"), src).as_deref(), Some("a\nb\nc"));
assert_eq!(preceding_docstring_skipping_trailing(func("f"), src).as_deref(), Some("c"));
assert_eq!(preceding_docstring(func("g"), src).as_deref(), Some("d"));
assert_eq!(preceding_docstring_skipping_trailing(func("g"), src), None);
}
}
+30 -5
View File
@@ -16,7 +16,7 @@ use crate::buffers::{
build_meta, edge_kind_index, node_kind_index, Arena, BoolFlags, EdgeRow, EmitOut, NodeRow,
RefRow, StrRef, Tables, FLAG_IS_EXPORTED, FUNCTION_REF_CODE, NONE, NONE_STR,
};
use crate::docstring::preceding_docstring;
use crate::docstring::preceding_docstring_skipping_trailing;
use crate::ids;
use crate::textutil as util;
use regex::Regex;
@@ -380,6 +380,31 @@ impl<'t> Walker<'t> {
receiver_re().captures(text).map(|c| c[1].to_string())
}
/// goExtractor.getDeclarationWrapper: a type declared on its own, `type
/// Foo struct{…}`, is a `type_declaration` holding one spec, and its doc
/// comment comes before the declaration, outside the spec. A member of a
/// `type ( … )` group has its comment beside it, and the comment above the
/// group is the group's. A group of one is that type's declaration, as go
/// doc reads it, unless its member has a comment of its own.
fn declaration_wrapper(&self, node: Node<'t>) -> Option<Node<'t>> {
let parent = node.parent()?;
if parent.kind() != "type_declaration" || parent.named_child(0)? != node {
return None;
}
let specs = (0..parent.named_child_count())
.filter_map(|i| parent.named_child(i))
.filter(|c| matches!(c.kind(), "type_spec" | "type_alias"))
.count();
(specs == 1).then_some(parent)
}
/// docstringFor (tree-sitter.ts) — the preceding comment run, looked up
/// from the declaration wrapper when there is one, without the comments
/// that trail the code above it (docstringSkipsTrailingComments).
fn docstring_of(&self, node: Node<'t>) -> Option<String> {
preceding_docstring_skipping_trailing(self.declaration_wrapper(node).unwrap_or(node), self.src)
}
// --- visitNode ------------------------------------------------------------
fn visit_node(&mut self, node: Node<'t>) {
@@ -465,7 +490,7 @@ impl<'t> Walker<'t> {
return;
}
let extra = Extra {
docstring: preceding_docstring(node, self.src),
docstring: self.docstring_of(node),
signature: self.signature_of(node),
is_exported: Some(self.is_exported(node)),
return_type: self.return_type_of(node),
@@ -487,7 +512,7 @@ impl<'t> Walker<'t> {
let receiver_type = self.receiver_type_of(node);
let name = self.extract_name(node);
let extra = Extra {
docstring: preceding_docstring(node, self.src),
docstring: self.docstring_of(node),
signature: self.signature_of(node),
return_type: self.return_type_of(node),
qualified_name: receiver_type.as_ref().map(|r| format!("{r}::{name}")),
@@ -537,7 +562,7 @@ impl<'t> Walker<'t> {
if name == "<anonymous>" {
return false;
}
let docstring = preceding_docstring(node, self.src);
let docstring = self.docstring_of(node);
let is_exported = Some(self.is_exported(node));
let type_child = node.child_by_field_name("type");
let resolved = type_child.map(|t| t.kind());
@@ -623,7 +648,7 @@ impl<'t> Walker<'t> {
/// extractVariable's Go branch: var/const specs + short_var_declaration.
fn extract_variable(&mut self, node: Node<'t>) {
let docstring = preceding_docstring(node, self.src);
let docstring = self.docstring_of(node);
let is_const_decl = node.kind() == "const_declaration";
for i in 0..node.named_child_count() {