Skip to content

Commit 4596891

Browse files
committed
fix(langserver): prevent inconsistent Robocop formatting
Formatting a file with Robocop repeatedly no longer oscillates between states (for example a blank line being added and removed before a section header on every other format). The formatter mutated the shared, cached document model in place; it now works on a fresh, uncached model so repeated formatting stays idempotent. Fixes #612
1 parent e3bad18 commit 4596891

4 files changed

Lines changed: 232 additions & 1 deletion

File tree

packages/language_server/src/robotcode/language_server/robotframework/parts/formatting.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ def format_robocop(
8989
runner = RobocopFormatter(config_manager)
9090
runner.config = config
9191

92-
model = self.parent.documents_cache.get_model(document)
92+
model = self.parent.documents_cache.get_uncached_model(document)
9393
if self.parent.robocop_helper.robocop_version >= (8, 0):
9494
from robocop.source_file import SourceFile
9595

packages/robot/src/robotcode/robot/diagnostics/document_cache_helper.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,23 @@ def get_model(self, document: TextDocument) -> ast.AST:
303303

304304
return self.get_general_model(document)
305305

306+
def get_uncached_model(self, document: TextDocument) -> ast.AST:
307+
"""Build a fresh model that is never cached on the document.
308+
309+
The cached model returned by `get_model` is shared between all features.
310+
Consumers that mutate the model in place (e.g. the Robocop formatter) must
311+
use this instead, otherwise the mutations corrupt the cached model and break
312+
subsequent operations.
313+
"""
314+
document_type = self.get_document_type(document)
315+
316+
if document_type == DocumentType.INIT:
317+
return self.__get_init_model(document)
318+
if document_type == DocumentType.RESOURCE:
319+
return self.__get_resource_model(document)
320+
321+
return self.__get_general_model(document)
322+
306323
def __get_model(
307324
self,
308325
document: TextDocument,
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
"""Regression tests for Robocop based formatting.
2+
3+
Reproduces https://github.com/robotcodedev/robotcode/issues/612: formatting an
4+
already formatted document repeatedly must be a no-op. The Robocop formatter
5+
mutates the model in place, so `format_robocop` has to work on an *uncached*
6+
model - otherwise the mutation corrupts the shared cached model and repeated
7+
formatting starts to oscillate (a blank line is added, removed, added, ...).
8+
9+
The test drives the real `RobotFormattingProtocolPart.format_robocop` and only
10+
fakes the surrounding protocol wiring, so a regression in the model handling of
11+
the formatter is caught here.
12+
"""
13+
14+
from pathlib import Path
15+
from types import SimpleNamespace
16+
from typing import Any, List, Optional
17+
from unittest.mock import MagicMock
18+
19+
import pytest
20+
21+
from robotcode.core.lsp.types import FormattingOptions, TextEdit
22+
from robotcode.core.text_document import TextDocument
23+
from robotcode.core.uri import Uri
24+
from robotcode.core.utils.version import create_version_from_str
25+
from robotcode.core.workspace import WorkspaceFolder
26+
from robotcode.language_server.robotframework.parts.formatting import RobotFormattingProtocolPart
27+
from robotcode.robot.diagnostics.document_cache_helper import DocumentsCacheHelper
28+
from robotcode.robot.diagnostics.workspace_config import RobotConfig
29+
30+
# Importing robotcode above does not pull in robocop (the formatter imports it
31+
# lazily), so the skip guard can live after the regular imports.
32+
robocop = pytest.importorskip("robocop")
33+
34+
ROBOCOP_VERSION = create_version_from_str(robocop.__version__)
35+
36+
pytestmark = pytest.mark.skipif(
37+
ROBOCOP_VERSION < (6, 0),
38+
reason="Robocop >= 6.0 is required for formatting",
39+
)
40+
41+
UNFORMATTED = (
42+
'*** Test Cases ***\nTest\n Embedded "args"\n\n\n*** Keywords ***\nEmbedded "${args}"\n No Operation\n'
43+
)
44+
45+
# Both `section_lines` and `test_case_lines` greater than zero is what makes a
46+
# single NormalizeNewLines pass non-idempotent on this input (see #612/#361).
47+
CONFIGURE_VARIANTS = [
48+
pytest.param(["NormalizeNewLines.enabled=True"], id="defaults"),
49+
pytest.param(
50+
[
51+
"NormalizeNewLines.enabled=True",
52+
"NormalizeNewLines.section_lines=2",
53+
"NormalizeNewLines.test_case_lines=1",
54+
],
55+
id="explicit-section-and-test-case-lines",
56+
),
57+
]
58+
59+
60+
def _config_manager(root: Path) -> Any:
61+
if ROBOCOP_VERSION >= (8, 0):
62+
from robocop.config.manager import ConfigManager
63+
else:
64+
from robocop.config import ConfigManager
65+
66+
return ConfigManager([], root=root, config=root / "robot.toml")
67+
68+
69+
def _make_formatting_part(root: Path, document: TextDocument) -> RobotFormattingProtocolPart:
70+
folder = WorkspaceFolder(name="test", uri=Uri.from_path(root))
71+
72+
workspace = MagicMock()
73+
workspace.get_workspace_folder.return_value = folder
74+
workspace.get_configuration.return_value = RobotConfig()
75+
76+
documents_cache = DocumentsCacheHelper(
77+
workspace=workspace,
78+
documents_manager=MagicMock(),
79+
file_watcher_manager=MagicMock(),
80+
robot_profile=None,
81+
analysis_config=None,
82+
)
83+
84+
config_manager = _config_manager(root)
85+
robocop_helper = SimpleNamespace(
86+
robocop_installed=True,
87+
robocop_version=ROBOCOP_VERSION,
88+
get_config_manager=lambda _folder: config_manager,
89+
)
90+
91+
parent = SimpleNamespace(
92+
workspace=workspace,
93+
robocop_helper=robocop_helper,
94+
documents_cache=documents_cache,
95+
)
96+
97+
part = object.__new__(RobotFormattingProtocolPart)
98+
part._parent = parent # type: ignore[assignment]
99+
return part
100+
101+
102+
@pytest.fixture
103+
def options() -> FormattingOptions:
104+
return FormattingOptions(tab_size=4, insert_spaces=True)
105+
106+
107+
@pytest.mark.parametrize("configure", CONFIGURE_VARIANTS)
108+
def test_repeated_formatting_is_idempotent(tmp_path: Path, options: FormattingOptions, configure: List[str]) -> None:
109+
"""After reaching a fixed point, formatting again must not change anything."""
110+
configure_lines = "\n".join(f' "{c}",' for c in configure)
111+
(tmp_path / "robot.toml").write_text(f"[tool.robocop.format]\nconfigure = [\n{configure_lines}\n]\n")
112+
113+
source = tmp_path / "test.robot"
114+
source.write_text(UNFORMATTED)
115+
116+
document = TextDocument(
117+
document_uri=str(Uri.from_path(source).normalized()),
118+
language_id="robotframework",
119+
version=1,
120+
text=UNFORMATTED,
121+
)
122+
123+
part = _make_formatting_part(tmp_path, document)
124+
125+
def format_once() -> Optional[List[TextEdit]]:
126+
return part.format_robocop(document, options)
127+
128+
# Reach the formatter's fixed point first (apply the initial reformat, if any).
129+
edits = format_once()
130+
if edits:
131+
document.apply_full_change((document.version or 0) + 1, edits[0].new_text)
132+
133+
# From the fixed point, formatting must stay a no-op - no oscillation (#612).
134+
for _ in range(5):
135+
edits = format_once()
136+
if edits:
137+
document.apply_full_change((document.version or 0) + 1, edits[0].new_text)
138+
assert edits is None, f"formatting is not idempotent, produced: {edits!r}"
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
"""Tests for `DocumentsCacheHelper` model caching.
2+
3+
`get_model` returns a model that is cached on (and shared across) the document,
4+
while `get_uncached_model` must always return a fresh, independent model. The
5+
Robocop formatter mutates the model in place, so it relies on the uncached
6+
variant - otherwise the mutations corrupt the shared cached model and repeated
7+
formatting becomes non-idempotent (see
8+
https://github.com/robotcodedev/robotcode/issues/612).
9+
"""
10+
11+
from typing import cast
12+
from unittest.mock import MagicMock
13+
14+
import pytest
15+
from robot.parsing.model.blocks import File
16+
17+
from robotcode.core.text_document import TextDocument
18+
from robotcode.core.uri import Uri
19+
from robotcode.robot.diagnostics.document_cache_helper import DocumentsCacheHelper
20+
21+
TEXT = '*** Test Cases ***\nTest\n Embedded "args"\n\n\n*** Keywords ***\nEmbedded "${args}"\n No Operation\n'
22+
23+
24+
@pytest.fixture
25+
def cache_helper() -> DocumentsCacheHelper:
26+
workspace = MagicMock()
27+
workspace.get_workspace_folder.return_value = None
28+
return DocumentsCacheHelper(
29+
workspace=workspace,
30+
documents_manager=MagicMock(),
31+
file_watcher_manager=MagicMock(),
32+
robot_profile=None,
33+
analysis_config=None,
34+
)
35+
36+
37+
def _document(name: str = "test.robot") -> TextDocument:
38+
return TextDocument(
39+
document_uri=str(Uri.from_path(f"/tmp/{name}").normalized()),
40+
language_id="robotframework",
41+
version=1,
42+
text=TEXT,
43+
)
44+
45+
46+
def test_get_model_is_cached_on_the_document(cache_helper: DocumentsCacheHelper) -> None:
47+
document = _document()
48+
49+
assert cache_helper.get_model(document) is cache_helper.get_model(document)
50+
51+
52+
@pytest.mark.parametrize("name", ["test.robot", "test.resource", "__init__.robot"])
53+
def test_get_uncached_model_returns_a_fresh_model_every_time(cache_helper: DocumentsCacheHelper, name: str) -> None:
54+
document = _document(name)
55+
56+
first = cache_helper.get_uncached_model(document)
57+
second = cache_helper.get_uncached_model(document)
58+
59+
assert isinstance(first, File)
60+
assert first is not second
61+
assert first is not cache_helper.get_model(document)
62+
63+
64+
def test_uncached_model_mutation_does_not_corrupt_the_cached_model(
65+
cache_helper: DocumentsCacheHelper,
66+
) -> None:
67+
document = _document()
68+
69+
cached = cast(File, cache_helper.get_model(document))
70+
sections_before = len(cached.sections)
71+
72+
uncached = cast(File, cache_helper.get_uncached_model(document))
73+
uncached.sections.clear()
74+
75+
assert len(cached.sections) == sections_before
76+
assert cache_helper.get_model(document) is cached

0 commit comments

Comments
 (0)