Skip to content

Commit ec8cfe1

Browse files
committed
Fix gopls replace_symbol_body corrupting type/var/const declarations
gopls reports the document-symbol range of single type/var/const declarations starting at the identifier (after the keyword), unlike func declarations whose range includes the func keyword. Serena derives both the displayed body and the replacement range from that range, so the keyword was dropped from both for these declarations and a keyword-inclusive edit corrupted the file (type Foo became type type Foo). Override request_document_symbols in gopls to extend the range start back to the keyword when only the keyword and indentation precede the identifier, recomputing the body so it stays consistent with the replacement range. Funcs, grouped declarations, and selectionRange are left untouched. Adds a unit test for the symbol body and range plus an end-to-end replace_symbol_body round-trip regression test.
1 parent 8a54795 commit ec8cfe1

6 files changed

Lines changed: 253 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ Status of the `main` branch. Changes prior to the next official version change w
99
at the Yarn-generated SDK.
1010
- `SvelteLanguageServer`: Fix diagnostics requests for TypeScript/JavaScript files incorrectly being
1111
processed by the Svelte LS instead of the TypeScript LS.
12+
- `gopls`: Fix `replace_symbol_body` corrupting single `type`/`var`/`const` declarations by
13+
duplicating the leading keyword (e.g. `type Foo` becoming `type type Foo`). gopls reports the
14+
symbol range of such declarations starting at the identifier rather than the keyword (unlike
15+
`func` declarations); the range is now extended to include the keyword so the body and the
16+
replacement range stay consistent.
1217

1318
* Dashboard:
1419
- Make list of trusted hosts configurable, fixing host validation introduced in v1.5.2 allowing only

src/solidlsp/language_servers/gopls.py

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@
33
import logging
44
import os
55
import pathlib
6+
import re
67
import subprocess
78
from collections.abc import Hashable
89
from typing import Any, cast
910

1011
from overrides import override
1112

12-
from solidlsp.ls import RawDocumentSymbol, SolidLanguageServer
13+
from solidlsp import ls_types
14+
from solidlsp.ls import DocumentSymbols, LSPFileBuffer, RawDocumentSymbol, SolidLanguageServer, SymbolBodyFactory
1315
from solidlsp.ls_config import LanguageServerConfig
1416
from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams
1517
from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo
@@ -23,6 +25,12 @@ class Gopls(SolidLanguageServer):
2325
Provides Go specific instantiation of the LanguageServer class using gopls.
2426
"""
2527

28+
# matches a line prefix that consists solely of a leading `type`/`var`/`const` declaration
29+
# keyword (with optional indentation and the whitespace before the declared identifier).
30+
# gopls reports the symbol range of such single declarations starting at the identifier,
31+
# i.e. after the keyword, whereas `func` declarations include the `func` keyword.
32+
_LEADING_DECL_KEYWORD_RE = re.compile(r"(?P<indent>\s*)(?:type|var|const)\s+")
33+
2634
@classmethod
2735
def supports_implementation_request(cls) -> bool:
2836
return True
@@ -214,6 +222,92 @@ def _document_symbols_cache_fingerprint(self) -> Hashable:
214222
def _normalize_symbol_name(self, symbol: RawDocumentSymbol, relative_file_path: str) -> str:
215223
return symbol["name"].rsplit(".", 1)[-1]
216224

225+
@override
226+
def request_document_symbols(self, relative_file_path: str, file_buffer: LSPFileBuffer | None = None) -> DocumentSymbols:
227+
# Override to extend single `type`/`var`/`const` declaration ranges to include the leading
228+
# keyword. gopls excludes the keyword from such ranges (unlike `func` declarations), which
229+
# causes replace_symbol_body to drop the keyword from the symbol body and replacement range;
230+
# a natural keyword-inclusive round-trip edit would then corrupt the file (e.g. `type Foo`
231+
# becomes `type type Foo`). See _extend_go_symbol_range_to_include_leading_keyword.
232+
document_symbols = super().request_document_symbols(relative_file_path, file_buffer=file_buffer)
233+
if not document_symbols.root_symbols:
234+
return document_symbols
235+
236+
# obtain the file lines and a body factory to recompute the bodies of extended symbols
237+
with self._open_file_context(relative_file_path, file_buffer, open_in_ls=False) as file_data:
238+
file_lines = file_data.split_lines()
239+
body_factory = SymbolBodyFactory(file_data)
240+
241+
# extend ranges recursively, operating on copies so the cached symbols are not mutated.
242+
# Children keep their `parent` back-pointer aimed at the original (un-extended) node;
243+
# this is intentional and safe, because ancestor traversal only reads name/kind/overload
244+
# index (see Symbol.iter_ancestors), none of which the extension changes. Rebinding the
245+
# back-pointers would force a deep copy of every extended subtree for no observable gain.
246+
def extend_symbol_and_children(symbol: ls_types.UnifiedSymbolInformation) -> ls_types.UnifiedSymbolInformation:
247+
extended = self._extend_go_symbol_range_to_include_leading_keyword(symbol, file_lines, body_factory)
248+
children = symbol.get("children")
249+
if children:
250+
if extended is symbol:
251+
extended = symbol.copy()
252+
extended["children"] = [extend_symbol_and_children(child) for child in children]
253+
return extended
254+
255+
extended_root_symbols = [extend_symbol_and_children(sym) for sym in document_symbols.root_symbols]
256+
257+
return DocumentSymbols(extended_root_symbols)
258+
259+
def _extend_go_symbol_range_to_include_leading_keyword(
260+
self,
261+
symbol: ls_types.UnifiedSymbolInformation,
262+
file_lines: list[str],
263+
body_factory: SymbolBodyFactory,
264+
) -> ls_types.UnifiedSymbolInformation:
265+
"""
266+
Extend a Go symbol's body range to include a leading `type`/`var`/`const` keyword.
267+
268+
gopls reports the range of a single `type`/`var`/`const` declaration starting at the
269+
declared identifier (after the keyword), whereas the range of a `func` declaration includes
270+
the `func` keyword. This asymmetry makes :meth:`replace_symbol_body` omit the keyword from
271+
both the displayed body and the replacement range, so re-supplying the keyword in an edit
272+
corrupts the file (e.g. ``type Foo`` becomes ``type type Foo``).
273+
274+
:param symbol: the symbol whose range may be extended.
275+
:param file_lines: the lines of the file in which the symbol is defined.
276+
:param body_factory: the factory used to recompute the symbol body from the extended range.
277+
:return: a copy of the symbol with an extended range, or the original symbol if no leading
278+
keyword precedes the identifier on the start line (e.g. for funcs or for grouped
279+
declarations such as ``var ( ... )`` whose keyword sits on a separate line).
280+
"""
281+
# determine whether only a declaration keyword precedes the identifier on the start line
282+
range_info = symbol["range"]
283+
start_line = range_info["start"]["line"]
284+
start_char = range_info["start"]["character"]
285+
if start_line >= len(file_lines):
286+
return symbol
287+
prefix = file_lines[start_line][:start_char]
288+
match = self._LEADING_DECL_KEYWORD_RE.fullmatch(prefix)
289+
if match is None:
290+
return symbol
291+
292+
# extend the range start back to the keyword (excluding indentation), updating both the
293+
# symbol range and its location range so the replacement range covers the keyword
294+
new_start = ls_types.Position(line=start_line, character=len(match.group("indent")))
295+
extended = symbol.copy()
296+
extended["range"] = ls_types.Range(start=new_start, end=range_info["end"])
297+
location = extended.get("location")
298+
if location:
299+
location = location.copy()
300+
if "range" in location:
301+
location["range"] = ls_types.Range(start=new_start, end=location["range"]["end"])
302+
extended["location"] = location
303+
304+
# recompute the body from the now-extended location range so the displayed body stays
305+
# consistent with the replacement range; the stale body must be removed first, since the
306+
# factory returns an existing SymbolBody as-is and otherwise reads the updated location range
307+
extended.pop("body", None)
308+
extended["body"] = body_factory.create_symbol_body(extended)
309+
return extended
310+
217311
def _start_server(self) -> None:
218312
"""Start gopls server process"""
219313

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package main
2+
3+
// BodyStruct is a single type declaration used to verify that replace_symbol_body
4+
// includes the leading `type` keyword in the symbol body and replacement range.
5+
type BodyStruct struct {
6+
Value int
7+
}
8+
9+
// NamedInt is a single defined-type declaration (non-struct).
10+
type NamedInt int
11+
12+
// AliasInt is a type alias declaration.
13+
type AliasInt = int
14+
15+
// GlobalCounter is a single package-level var declaration.
16+
var GlobalCounter int = 0
17+
18+
// MaxItems is a single package-level const declaration.
19+
const MaxItems = 100
20+
21+
// GroupedA and GroupedB are declared in a grouped var block, where the `var`
22+
// keyword is on a separate line and must NOT be folded into either symbol body.
23+
var (
24+
GroupedA int = 1
25+
GroupedB string = "two"
26+
)

test/serena/__snapshots__/test_symbol_editing.ambr

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,37 @@
310310

311311
'''
312312
# ---
313+
# name: test_go_symbol_replacement_no_double_keyword
314+
'''
315+
package main
316+
317+
// BodyStruct is a single type declaration used to verify that replace_symbol_body
318+
// includes the leading `type` keyword in the symbol body and replacement range.
319+
type BodyStruct struct {
320+
Value int
321+
}
322+
323+
// NamedInt is a single defined-type declaration (non-struct).
324+
type NamedInt int64
325+
326+
// AliasInt is a type alias declaration.
327+
type AliasInt = int
328+
329+
// GlobalCounter is a single package-level var declaration.
330+
var GlobalCounter int = 0
331+
332+
// MaxItems is a single package-level const declaration.
333+
const MaxItems = 100
334+
335+
// GroupedA and GroupedB are declared in a grouped var block, where the `var`
336+
// keyword is on a separate line and must NOT be folded into either symbol body.
337+
var (
338+
GroupedA int = 1
339+
GroupedB string = "two"
340+
)
341+
342+
'''
343+
# ---
313344
# name: test_insert_in_rel_to_symbol[test_case0-after]
314345
'''
315346
"""

test/serena/test_symbol_editing.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -474,6 +474,62 @@ def test_nix_symbol_replacement_no_double_semicolon(snapshot: SnapshotAssertion)
474474
test_case.run_test(content_after_ground_truth=snapshot)
475475

476476

477+
# A single `type` declaration body that re-supplies the leading `type` keyword, as a user would
478+
# after copying the body returned by find_symbol. Replacing the body with this must not duplicate
479+
# the keyword.
480+
GO_DECL_REPLACEMENT = """type NamedInt int64"""
481+
482+
483+
class GoDeclReplacementTest(EditingTest):
484+
"""Test for replacing a single Go ``type``/``var``/``const`` declaration that should NOT result
485+
in a duplicated leading keyword (e.g. ``type type NamedInt``).
486+
"""
487+
488+
def __init__(self, language: Language, rel_path: str, symbol_name: str, new_body: str):
489+
super().__init__(language, rel_path)
490+
self.symbol_name = symbol_name
491+
self.new_body = new_body
492+
493+
def _apply_edit(self, code_editor: CodeEditor) -> None:
494+
code_editor.replace_body(self.symbol_name, self.rel_path, self.new_body)
495+
496+
@overrides
497+
def _test_diff(self, code_diff: CodeDiff, snapshot: SnapshotAssertion) -> None:
498+
# the leading declaration keyword must appear exactly once (no `type type` corruption)
499+
for keyword in ("type", "var", "const"):
500+
assert f"{keyword} {keyword} " not in code_diff.modified_content, (
501+
f"Replacement duplicated the '{keyword}' keyword: {code_diff.modified_content!r}"
502+
)
503+
return super()._test_diff(code_diff, snapshot)
504+
505+
506+
@pytest.mark.go
507+
@pytest.mark.skipif(sys.platform == "win32", reason="gopls is not provisioned on the Windows CI runner")
508+
def test_go_symbol_replacement_no_double_keyword(snapshot: SnapshotAssertion):
509+
"""
510+
Test that replacing a Go ``type``/``var``/``const`` declaration does not duplicate the leading
511+
keyword.
512+
513+
This exercises the bug where:
514+
- Original: type NamedInt int
515+
- Replacement body (as displayed by find_symbol): type NamedInt int64
516+
- Bug result would be: type type NamedInt int64 (the original `type ` prefix is kept because the
517+
gopls symbol range starts at the identifier, and the supplied body adds another `type`)
518+
- Correct result should be: type NamedInt int64
519+
520+
gopls reports the symbol range of such declarations starting at the identifier (after the
521+
keyword), unlike ``func`` declarations whose range includes the ``func`` keyword. The gopls
522+
range extension prevents the duplicated keyword.
523+
"""
524+
test_case = GoDeclReplacementTest(
525+
Language.GO,
526+
"symbol_body.go",
527+
"NamedInt",
528+
GO_DECL_REPLACEMENT,
529+
)
530+
test_case.run_test(content_after_ground_truth=snapshot)
531+
532+
477533
class RenameSymbolTest(EditingTest):
478534
def __init__(self, language: Language, rel_path: str, symbol_name: str, new_name: str):
479535
super().__init__(language, rel_path)

test/solidlsp/go/test_go_basic.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,46 @@ def test_find_referencing_symbols(self, language_server: SolidLanguageServer) ->
4848
refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"])
4949
assert any("main.go" in ref.get("uri", "") for ref in refs), "Expected at least one reference result to point at main.go"
5050

51+
@pytest.mark.parametrize("language_server", [Language.GO], indirect=True)
52+
def test_type_var_const_body_includes_leading_keyword(self, language_server: SolidLanguageServer) -> None:
53+
"""
54+
Single ``type``/``var``/``const`` declarations must expose a body and replacement range that
55+
include the leading keyword, just like ``func`` declarations do.
56+
57+
Regression test for gopls reporting the symbol range of such declarations starting at the
58+
declared identifier (after the keyword) rather than at the keyword. That asymmetry made
59+
replace_symbol_body drop the keyword from the body and replacement range, so a natural
60+
keyword-inclusive round-trip edit corrupted the file (e.g. ``type Foo`` -> ``type type Foo``).
61+
"""
62+
all_symbols, _ = language_server.request_document_symbols("symbol_body.go").get_all_symbols_and_roots()
63+
symbols_by_name = {sym.get("name"): sym for sym in all_symbols}
64+
65+
# single declarations: body starts with the keyword, the range start moves to the keyword
66+
# (column 0 here), and the selection range still points at the identifier after the keyword
67+
expected_keyword_by_name = {
68+
"BodyStruct": "type ",
69+
"NamedInt": "type ",
70+
"AliasInt": "type ",
71+
"GlobalCounter": "var ",
72+
"MaxItems": "const ",
73+
}
74+
for name, keyword in expected_keyword_by_name.items():
75+
sym = symbols_by_name.get(name)
76+
assert sym is not None, f"{name} not found in symbol_body.go"
77+
body = sym["body"].get_text()
78+
assert body.startswith(keyword), f"Expected body of {name} to start with {keyword!r}, got {body[:24]!r}"
79+
assert sym["location"]["range"]["start"]["character"] == 0, f"Expected {name} body range to start at the keyword (col 0)"
80+
assert sym["selectionRange"]["start"]["character"] > 0, f"Expected {name} selectionRange to point at the identifier"
81+
82+
# grouped declarations keep the keyword on a separate line (e.g. ``var ( ... )``), so their
83+
# bodies must NOT include it and their ranges must be left untouched
84+
for name in ("GroupedA", "GroupedB"):
85+
sym = symbols_by_name.get(name)
86+
assert sym is not None, f"{name} not found in symbol_body.go"
87+
body = sym["body"].get_text()
88+
assert body.startswith(name), f"Expected grouped var {name} body to start with the identifier, got {body[:24]!r}"
89+
assert not body.startswith("var"), f"Grouped var {name} body must not include the 'var' keyword"
90+
5191
if language_has_verified_implementation_support(Language.GO):
5292

5393
@pytest.mark.parametrize("language_server", [Language.GO], indirect=True)

0 commit comments

Comments
 (0)