robotframework-lint-lsp 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. robotframework_lint_lsp-1.0.0/LICENSE +21 -0
  2. robotframework_lint_lsp-1.0.0/PKG-INFO +127 -0
  3. robotframework_lint_lsp-1.0.0/README.md +101 -0
  4. robotframework_lint_lsp-1.0.0/pyproject.toml +50 -0
  5. robotframework_lint_lsp-1.0.0/setup.cfg +4 -0
  6. robotframework_lint_lsp-1.0.0/src/robotframework_lint/__init__.py +3 -0
  7. robotframework_lint_lsp-1.0.0/src/robotframework_lint/autofix.py +63 -0
  8. robotframework_lint_lsp-1.0.0/src/robotframework_lint/hints.py +24 -0
  9. robotframework_lint_lsp-1.0.0/src/robotframework_lint/lsp.py +237 -0
  10. robotframework_lint_lsp-1.0.0/src/robotframework_lint/lsp_navigation.py +101 -0
  11. robotframework_lint_lsp-1.0.0/src/robotframework_lint/navigation.py +188 -0
  12. robotframework_lint_lsp-1.0.0/src/robotframework_lint/rules/__init__.py +1 -0
  13. robotframework_lint_lsp-1.0.0/src/robotframework_lint/rules/arguments.py +78 -0
  14. robotframework_lint_lsp-1.0.0/src/robotframework_lint/rules/documentation.py +78 -0
  15. robotframework_lint_lsp-1.0.0/src/robotframework_lint/rules/for_loops.py +55 -0
  16. robotframework_lint_lsp-1.0.0/src/robotframework_lint/rules/imports.py +55 -0
  17. robotframework_lint_lsp-1.0.0/src/robotframework_lint/rules/tag_patterns.py +88 -0
  18. robotframework_lint_lsp-1.0.0/src/robotframework_lint/rules/translations.py +75 -0
  19. robotframework_lint_lsp-1.0.0/src/robotframework_lint_lsp.egg-info/PKG-INFO +127 -0
  20. robotframework_lint_lsp-1.0.0/src/robotframework_lint_lsp.egg-info/SOURCES.txt +34 -0
  21. robotframework_lint_lsp-1.0.0/src/robotframework_lint_lsp.egg-info/dependency_links.txt +1 -0
  22. robotframework_lint_lsp-1.0.0/src/robotframework_lint_lsp.egg-info/entry_points.txt +6 -0
  23. robotframework_lint_lsp-1.0.0/src/robotframework_lint_lsp.egg-info/requires.txt +6 -0
  24. robotframework_lint_lsp-1.0.0/src/robotframework_lint_lsp.egg-info/top_level.txt +1 -0
  25. robotframework_lint_lsp-1.0.0/tests/test_arg_names.py +29 -0
  26. robotframework_lint_lsp-1.0.0/tests/test_autofix.py +103 -0
  27. robotframework_lint_lsp-1.0.0/tests/test_deprecated_library.py +30 -0
  28. robotframework_lint_lsp-1.0.0/tests/test_doc_tags.py +27 -0
  29. robotframework_lint_lsp-1.0.0/tests/test_entry_point.py +23 -0
  30. robotframework_lint_lsp-1.0.0/tests/test_for_in_zip_mode.py +28 -0
  31. robotframework_lint_lsp-1.0.0/tests/test_lsp.py +266 -0
  32. robotframework_lint_lsp-1.0.0/tests/test_lsp_navigation.py +101 -0
  33. robotframework_lint_lsp-1.0.0/tests/test_navigation.py +80 -0
  34. robotframework_lint_lsp-1.0.0/tests/test_plugin_config.py +22 -0
  35. robotframework_lint_lsp-1.0.0/tests/test_tag_pattern.py +45 -0
  36. robotframework_lint_lsp-1.0.0/tests/test_translation.py +39 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Abdulwahab Isam
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,127 @@
1
+ Metadata-Version: 2.4
2
+ Name: robotframework-lint-lsp
3
+ Version: 1.0.0
4
+ Summary: Robot Framework lint for coding agents: robocop rules, an LSP server and an auto-fix hook
5
+ Author: Abdulwahab Isam
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/ai94iq/robotframework-lint-lsp
8
+ Project-URL: Documentation, https://github.com/ai94iq/robotframework-lint-lsp/blob/main/docs/agents.md
9
+ Project-URL: Issues, https://github.com/ai94iq/robotframework-lint-lsp/issues
10
+ Keywords: robotframework,robocop,lint,lsp,claude-code
11
+ Classifier: Development Status :: 5 - Production/Stable
12
+ Classifier: Framework :: Robot Framework
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Software Development :: Quality Assurance
16
+ Classifier: Topic :: Software Development :: Testing
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Requires-Dist: robotframework==7.5.1
21
+ Requires-Dist: robotframework-robocop==9.4.0
22
+ Requires-Dist: pygls==2.1.1
23
+ Provides-Extra: dev
24
+ Requires-Dist: pytest==9.2.0; extra == "dev"
25
+ Dynamic: license-file
26
+
27
+ # robotframework-lint-lsp
28
+
29
+ Extra [Robocop](https://github.com/MarketSquare/robotframework-robocop) rules
30
+ for Robot Framework 7.5. When Robocop and the Robot Framework docs disagree,
31
+ these rules follow Robot Framework.
32
+
33
+ ## Install
34
+
35
+ ```bash
36
+ python -m venv .venv
37
+ .venv/Scripts/python -m pip install -e ".[dev]" # Windows
38
+ .venv/bin/python -m pip install -e ".[dev]" # Linux, macOS
39
+ ```
40
+
41
+ Pinned: robotframework 7.5.1, robotframework-robocop 9.4.0, pytest 9.2.0.
42
+
43
+ ## Run
44
+
45
+ ```bash
46
+ robocop check --custom-rules robotframework_lint path/to/tests
47
+ ```
48
+
49
+ The installed package registers the `robotframework_lint` Robocop plugin, so
50
+ the rules load by plugin name, no path needed. Run only these rules with
51
+ `--select RFL001 --select RFL002`.
52
+
53
+ ## Rules
54
+
55
+ | ID | Name | Source |
56
+ |---|---|---|
57
+ | RFL001 | for-in-zip-without-mode | [RF 7.0 release notes](https://github.com/robotframework/robotframework/blob/master/doc/releasenotes/rf-7.0.rst) |
58
+ | RFL002 | deprecated-standard-library (Telnet) | [RF 7.5 release notes](https://github.com/robotframework/robotframework/blob/master/doc/releasenotes/rf-7.5.rst) |
59
+ | RFL003 | deprecated-tag-pattern (`XORY`, `&`) | [RF 7.5 release notes](https://github.com/robotframework/robotframework/blob/master/doc/releasenotes/rf-7.5.rst) |
60
+ | RFL004 | deprecated-translation (French `Unités de test`, Finnish `Tagit`) | [RF 7.5 release notes](https://github.com/robotframework/robotframework/blob/master/doc/releasenotes/rf-7.5.rst) |
61
+ | RFL005 | doc-tags-without-empty-row (`Tags:` in keyword docs) | [RF 7.5 release notes](https://github.com/robotframework/robotframework/blob/master/doc/releasenotes/rf-7.5.rst) |
62
+ | RFL006 | deprecated-argument-name (`list_=`, `time_=`) | [RF 7.5 release notes](https://github.com/robotframework/robotframework/blob/master/doc/releasenotes/rf-7.5.rst) |
63
+
64
+ RFL003 asks Robot Framework's own `TagPatterns` whether a pattern is deprecated,
65
+ so it reports exactly what RF 7.5.1 warns about. That includes all-caps tags
66
+ holding an operator word, such as `-DONOTX` or `-ORANGE`: RF reads them as
67
+ `DO NOT X` and `OR ANGE`. Write such tags in lower case.
68
+
69
+ RFL004 reads the deprecated terms from Robot Framework's own language
70
+ definitions. It reports a term only when RF parsed it as a header or setting,
71
+ that is, when the file's language is French or Finnish (`language: fr` or
72
+ `language: fi` in the file, or `--language`).
73
+
74
+ ## LSP server (Claude Code and other agents)
75
+
76
+ `robotframework-lint-lsp` is a stdio language server. It lints the open
77
+ buffer in process with the robocop defaults plus these rules, on open, save
78
+ and change (debounced 0.4 s). Output is kept small for AI agents:
79
+
80
+ - one line per finding: `RULEID message`, at most 120 characters
81
+ - at most 20 findings per file (`maxDiagnostics`), then one `overflow` line
82
+ that says how many more were cut
83
+ - at most 50 symbols or references per answer
84
+ - warnings and errors only by default (`minSeverity`: info, warning, error)
85
+ - identical findings on one line reported once; no related information
86
+
87
+ - a finding robocop can fix ends with `fix all: <command>`; fixable Info
88
+ rules are raised to warning
89
+
90
+ Settings go in `initializationOptions`: `maxDiagnostics`, `minSeverity`,
91
+ `select`, `ignore`, `groupFindings` (default off). For Claude Code, copy
92
+ `.lsp.json` from this repo into your plugin; it maps `.robot` and
93
+ `.resource` to `robotframework`.
94
+
95
+ The plugin also ships a PostToolUse hook, `robotframework-lint-autofix`
96
+ (`hooks/hooks.json`): after each Edit or Write of a `.robot` or `.resource`
97
+ file it applies robocop's safe fixes, so the agent only sees what is left.
98
+
99
+ Setup for Claude Code, opencode, Crush, Codex and GLM:
100
+ [docs/agents.md](docs/agents.md). Measured results (cost, turns and issues
101
+ left, with and without the LSP, per model):
102
+ [docs/benchmarks.html](docs/benchmarks.html)
103
+ and [docs/benchmarks](docs/benchmarks).
104
+
105
+ The server also answers code navigation for user keywords:
106
+
107
+ - document symbols: tests, keywords and variables in a file
108
+ - definition and references: matched the way Robot matches names, so
109
+ `say_hello` finds `Say Hello`
110
+ - workspace symbols: find a keyword by name, exact matches first, then
111
+ prefix, then substring
112
+ - hover: the keyword signature and the first doc line, at most 200 characters
113
+
114
+ On start it indexes every `.robot` and `.resource` file under the workspace
115
+ root, up to 2000 files. It skips `.git`, `.venv`, `node_modules` and
116
+ `results`. Open files are indexed again on every change. Library keywords
117
+ such as `Log` and keywords with embedded arguments aren't indexed.
118
+
119
+ ## Tests
120
+
121
+ ```bash
122
+ python -m pytest
123
+ ```
124
+
125
+ ## License
126
+
127
+ MIT
@@ -0,0 +1,101 @@
1
+ # robotframework-lint-lsp
2
+
3
+ Extra [Robocop](https://github.com/MarketSquare/robotframework-robocop) rules
4
+ for Robot Framework 7.5. When Robocop and the Robot Framework docs disagree,
5
+ these rules follow Robot Framework.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ python -m venv .venv
11
+ .venv/Scripts/python -m pip install -e ".[dev]" # Windows
12
+ .venv/bin/python -m pip install -e ".[dev]" # Linux, macOS
13
+ ```
14
+
15
+ Pinned: robotframework 7.5.1, robotframework-robocop 9.4.0, pytest 9.2.0.
16
+
17
+ ## Run
18
+
19
+ ```bash
20
+ robocop check --custom-rules robotframework_lint path/to/tests
21
+ ```
22
+
23
+ The installed package registers the `robotframework_lint` Robocop plugin, so
24
+ the rules load by plugin name, no path needed. Run only these rules with
25
+ `--select RFL001 --select RFL002`.
26
+
27
+ ## Rules
28
+
29
+ | ID | Name | Source |
30
+ |---|---|---|
31
+ | RFL001 | for-in-zip-without-mode | [RF 7.0 release notes](https://github.com/robotframework/robotframework/blob/master/doc/releasenotes/rf-7.0.rst) |
32
+ | RFL002 | deprecated-standard-library (Telnet) | [RF 7.5 release notes](https://github.com/robotframework/robotframework/blob/master/doc/releasenotes/rf-7.5.rst) |
33
+ | RFL003 | deprecated-tag-pattern (`XORY`, `&`) | [RF 7.5 release notes](https://github.com/robotframework/robotframework/blob/master/doc/releasenotes/rf-7.5.rst) |
34
+ | RFL004 | deprecated-translation (French `Unités de test`, Finnish `Tagit`) | [RF 7.5 release notes](https://github.com/robotframework/robotframework/blob/master/doc/releasenotes/rf-7.5.rst) |
35
+ | RFL005 | doc-tags-without-empty-row (`Tags:` in keyword docs) | [RF 7.5 release notes](https://github.com/robotframework/robotframework/blob/master/doc/releasenotes/rf-7.5.rst) |
36
+ | RFL006 | deprecated-argument-name (`list_=`, `time_=`) | [RF 7.5 release notes](https://github.com/robotframework/robotframework/blob/master/doc/releasenotes/rf-7.5.rst) |
37
+
38
+ RFL003 asks Robot Framework's own `TagPatterns` whether a pattern is deprecated,
39
+ so it reports exactly what RF 7.5.1 warns about. That includes all-caps tags
40
+ holding an operator word, such as `-DONOTX` or `-ORANGE`: RF reads them as
41
+ `DO NOT X` and `OR ANGE`. Write such tags in lower case.
42
+
43
+ RFL004 reads the deprecated terms from Robot Framework's own language
44
+ definitions. It reports a term only when RF parsed it as a header or setting,
45
+ that is, when the file's language is French or Finnish (`language: fr` or
46
+ `language: fi` in the file, or `--language`).
47
+
48
+ ## LSP server (Claude Code and other agents)
49
+
50
+ `robotframework-lint-lsp` is a stdio language server. It lints the open
51
+ buffer in process with the robocop defaults plus these rules, on open, save
52
+ and change (debounced 0.4 s). Output is kept small for AI agents:
53
+
54
+ - one line per finding: `RULEID message`, at most 120 characters
55
+ - at most 20 findings per file (`maxDiagnostics`), then one `overflow` line
56
+ that says how many more were cut
57
+ - at most 50 symbols or references per answer
58
+ - warnings and errors only by default (`minSeverity`: info, warning, error)
59
+ - identical findings on one line reported once; no related information
60
+
61
+ - a finding robocop can fix ends with `fix all: <command>`; fixable Info
62
+ rules are raised to warning
63
+
64
+ Settings go in `initializationOptions`: `maxDiagnostics`, `minSeverity`,
65
+ `select`, `ignore`, `groupFindings` (default off). For Claude Code, copy
66
+ `.lsp.json` from this repo into your plugin; it maps `.robot` and
67
+ `.resource` to `robotframework`.
68
+
69
+ The plugin also ships a PostToolUse hook, `robotframework-lint-autofix`
70
+ (`hooks/hooks.json`): after each Edit or Write of a `.robot` or `.resource`
71
+ file it applies robocop's safe fixes, so the agent only sees what is left.
72
+
73
+ Setup for Claude Code, opencode, Crush, Codex and GLM:
74
+ [docs/agents.md](docs/agents.md). Measured results (cost, turns and issues
75
+ left, with and without the LSP, per model):
76
+ [docs/benchmarks.html](docs/benchmarks.html)
77
+ and [docs/benchmarks](docs/benchmarks).
78
+
79
+ The server also answers code navigation for user keywords:
80
+
81
+ - document symbols: tests, keywords and variables in a file
82
+ - definition and references: matched the way Robot matches names, so
83
+ `say_hello` finds `Say Hello`
84
+ - workspace symbols: find a keyword by name, exact matches first, then
85
+ prefix, then substring
86
+ - hover: the keyword signature and the first doc line, at most 200 characters
87
+
88
+ On start it indexes every `.robot` and `.resource` file under the workspace
89
+ root, up to 2000 files. It skips `.git`, `.venv`, `node_modules` and
90
+ `results`. Open files are indexed again on every change. Library keywords
91
+ such as `Log` and keywords with embedded arguments aren't indexed.
92
+
93
+ ## Tests
94
+
95
+ ```bash
96
+ python -m pytest
97
+ ```
98
+
99
+ ## License
100
+
101
+ MIT
@@ -0,0 +1,50 @@
1
+ [build-system]
2
+ requires = ["setuptools>=69,<85"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "robotframework-lint-lsp"
7
+ version = "1.0.0"
8
+ description = "Robot Framework lint for coding agents: robocop rules, an LSP server and an auto-fix hook"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ authors = [{ name = "Abdulwahab Isam" }]
13
+ keywords = ["robotframework", "robocop", "lint", "lsp", "claude-code"]
14
+ classifiers = [
15
+ "Development Status :: 5 - Production/Stable",
16
+ "Framework :: Robot Framework",
17
+ "Intended Audience :: Developers",
18
+ "Programming Language :: Python :: 3",
19
+ "Topic :: Software Development :: Quality Assurance",
20
+ "Topic :: Software Development :: Testing",
21
+ ]
22
+ dependencies = [
23
+ "robotframework==7.5.1",
24
+ "robotframework-robocop==9.4.0",
25
+ "pygls==2.1.1",
26
+ ]
27
+
28
+ [project.urls]
29
+ Homepage = "https://github.com/ai94iq/robotframework-lint-lsp"
30
+ Documentation = "https://github.com/ai94iq/robotframework-lint-lsp/blob/main/docs/agents.md"
31
+ Issues = "https://github.com/ai94iq/robotframework-lint-lsp/issues"
32
+
33
+ [project.optional-dependencies]
34
+ dev = ["pytest==9.2.0"]
35
+
36
+ [project.scripts]
37
+ robotframework-lint-lsp = "robotframework_lint.lsp:main"
38
+ robotframework-lint-autofix = "robotframework_lint.autofix:main"
39
+
40
+ [project.entry-points."robocop.plugins"]
41
+ robotframework_lint = "robotframework_lint.rules"
42
+
43
+ [tool.setuptools.packages.find]
44
+ where = ["src"]
45
+
46
+ [tool.pytest.ini_options]
47
+ testpaths = ["tests"]
48
+
49
+ [tool.robocop.lint]
50
+ custom_rules = ["src/robotframework_lint/rules"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """Robocop rule pack for Robot Framework."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,63 @@
1
+ """PostToolUse hook: apply safe robocop fixes to a Robot file right after an agent edits it.
2
+
3
+ A/B runs showed agents read "fix all: <command>" hints but never ran them, so the
4
+ fixable findings stayed. Fixing here costs the agent no turns. Silent by design:
5
+ the LSP push reports whatever is left.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import subprocess
12
+ import sys
13
+ import tempfile
14
+ from pathlib import Path
15
+
16
+ from .hints import FIX_COMMANDS
17
+
18
+ ROBOT_SUFFIXES = {".robot", ".resource"}
19
+ # Two robocop runs must finish inside the hook's 120 s budget.
20
+ TIMEOUT_S = 50
21
+
22
+
23
+ def fix_file(path: Path) -> None:
24
+ # -I: the hook runs inside the edited repo, which must not shadow robocop with its own module.
25
+ robocop = [sys.executable, "-I", "-m", "robocop"]
26
+ check_rules = [rule for rule, cmd in FIX_COMMANDS.items() if cmd.startswith("robocop check")]
27
+ selects = [arg for rule in check_rules for arg in ("--select", rule)]
28
+ original = path.read_text(encoding="utf-8", newline="")
29
+ # A temp copy keeps robocop from reading the repo's config, which can name Python rule files.
30
+ with tempfile.TemporaryDirectory() as tmp:
31
+ copy = Path(tmp) / f"buffer{path.suffix}"
32
+ copy.write_text(original, encoding="utf-8", newline="")
33
+ # TMPDIR can sit under a folder with its own robocop config; an explicit empty one wins.
34
+ config = Path(tmp) / "robocop.toml"
35
+ config.write_text("", encoding="utf-8")
36
+ # Argument lists, never a shell: the path comes from the hook payload.
37
+ for args in (["format", "--select", "ReplaceWithVAR", "--silent"], ["check", "--fix", *selects]):
38
+ subprocess.run([*robocop, *args, "--config", str(config), "--root", tmp, "--", str(copy)], cwd=tmp,
39
+ capture_output=True, timeout=TIMEOUT_S, check=False)
40
+ fixed = copy.read_text(encoding="utf-8") # robocop writes the OS line ending
41
+ if "\r\n" in original:
42
+ fixed = fixed.replace("\n", "\r\n")
43
+ # The agent may have written again while robocop ran; never overwrite the newer text.
44
+ if fixed != original and path.read_text(encoding="utf-8", newline="") == original:
45
+ path.write_text(fixed, encoding="utf-8", newline="")
46
+
47
+
48
+ def main() -> int:
49
+ try:
50
+ payload = json.loads(sys.stdin.read())
51
+ path = Path(payload["tool_input"]["file_path"])
52
+ except (ValueError, KeyError, TypeError):
53
+ return 0
54
+ if path.suffix.lower() in ROBOT_SUFFIXES and path.is_file():
55
+ try:
56
+ fix_file(path)
57
+ except (OSError, ValueError, subprocess.TimeoutExpired):
58
+ pass # a failed fix must never block the agent's edit
59
+ return 0
60
+
61
+
62
+ if __name__ == "__main__":
63
+ sys.exit(main())
@@ -0,0 +1,24 @@
1
+ """One-line fix hints for the findings agents most often left unfixed.
2
+
3
+ Robocop has no auto-fix for these, and agents without a hint spent extra
4
+ edit rounds guessing the modern form or left the finding in place.
5
+ """
6
+
7
+ # One shell command fixes every finding of the rule in the file (checked on robocop 9.4.0).
8
+ FIX_COMMANDS = {
9
+ "DEPR05": "robocop format --select ReplaceWithVAR <file>",
10
+ "DEPR06": "robocop format --select ReplaceWithVAR <file>",
11
+ **{rule: f"robocop check --fix --select {rule} <file>"
12
+ for rule in ("DEPR03", "DEPR04", "DEPR07", "DEPR08", "DEPR09", "DEPR10", "DEPR11",
13
+ "MISC04", "MISC09", "VAR01")},
14
+ }
15
+
16
+ FIX_HINTS = {
17
+ "MISC09": "use inline IF: IF cond Keyword ELSE Keyword",
18
+ "SPC03": "use exactly 2 empty lines between sections",
19
+ "VAR02": "drop the assignment or use the variable",
20
+ "VAR05": "pass the value as an argument or RETURN it",
21
+ "VAR06": "pass the value as an argument or RETURN it",
22
+ "VAR10": "spell the variable the same way everywhere",
23
+ "MISC04": "use one assignment style in the file",
24
+ }
@@ -0,0 +1,237 @@
1
+ """Minimal LSP server: robocop defaults plus our rules, token-cheap diagnostics.
2
+
3
+ Token controls: one short line per finding ("RULEID message", capped length),
4
+ a per-file cap, a severity floor, dedup of identical findings, and no related
5
+ information, code descriptions or data payloads.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import tempfile
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+
15
+ from lsprotocol import types
16
+ from pygls.lsp.server import LanguageServer
17
+
18
+ from .hints import FIX_COMMANDS, FIX_HINTS
19
+ from .lsp_navigation import register as register_navigation
20
+ from .lsp_navigation import scan_workspace
21
+ from .navigation import WorkspaceIndex
22
+
23
+ RULES_DIR = Path(__file__).parent / "rules"
24
+ MAX_MESSAGE = 120
25
+ MAX_LINT_BYTES = 1_000_000
26
+ DEBOUNCE_SECONDS = 0.4
27
+ SEVERITY_ORDER = {"info": 0, "warning": 1, "error": 2}
28
+ LSP_SEVERITY = {
29
+ "info": types.DiagnosticSeverity.Information,
30
+ "warning": types.DiagnosticSeverity.Warning,
31
+ "error": types.DiagnosticSeverity.Error,
32
+ }
33
+ MAX_GROUP_LINES = 6
34
+
35
+
36
+ @dataclass
37
+ class Settings:
38
+ max_diagnostics: int = 20
39
+ min_severity: str = "warning"
40
+ select: list[str] = field(default_factory=list)
41
+ ignore: list[str] = field(default_factory=list)
42
+ group: bool = False # v4 A/B: grouped findings left more DEPR05/06 unfixed
43
+
44
+ @classmethod
45
+ def from_options(cls, options: object) -> Settings:
46
+ opts = options if isinstance(options, dict) else {}
47
+ severity = str(opts.get("minSeverity", "warning")).lower()
48
+ if severity not in SEVERITY_ORDER:
49
+ severity = "warning"
50
+ cap = opts.get("maxDiagnostics", 20)
51
+ cap = cap if isinstance(cap, int) and cap > 0 else 20
52
+ group = opts.get("groupFindings", False) is True
53
+ return cls(cap, severity, _str_list(opts.get("select")), _str_list(opts.get("ignore")), group)
54
+
55
+
56
+ def _str_list(value: object) -> list[str]:
57
+ return [str(v) for v in value] if isinstance(value, list) else []
58
+
59
+
60
+ def _severity_name(robocop_severity: object) -> str:
61
+ letter = getattr(robocop_severity, "value", "W")
62
+ return {"I": "info", "W": "warning", "E": "error"}.get(letter, "warning")
63
+
64
+
65
+ def _run_robocop(text: str, suffix: str, settings: Settings) -> list:
66
+ from robocop.config.manager import ConfigManager
67
+ from robocop.config.schema import RawConfig, RawLinterConfig
68
+ from robocop.linter.rules import RuleSeverity
69
+ from robocop.linter.runner import RobocopLinter
70
+ from robocop.source_file import SourceFile
71
+
72
+ with tempfile.TemporaryDirectory() as tmp:
73
+ path = Path(tmp) / f"buffer{suffix}"
74
+ path.write_text(text, encoding="utf-8")
75
+ linter_config = RawLinterConfig(
76
+ select=settings.select or None,
77
+ ignore=settings.ignore or None,
78
+ threshold=RuleSeverity("I"), # floor applied after promotion in lint_text
79
+ custom_rules=[str(RULES_DIR)],
80
+ return_result=True,
81
+ )
82
+ config = RawConfig(sources=[str(path)], linter=linter_config, silent=True)
83
+ # A robocop or pyproject file above the temp dir must not pick rules or rule files.
84
+ manager = ConfigManager(sources=[str(path)], root=Path(tmp), ignore_file_config=True,
85
+ overwrite_config=config)
86
+ linter = RobocopLinter(manager)
87
+ return list(linter.run_check(SourceFile(path=path, config=manager.default_config)))
88
+
89
+
90
+ def lint_text(text: str, settings: Settings, suffix: str = ".robot") -> list[types.Diagnostic]:
91
+ """Lint a buffer and return capped, filtered, deduplicated LSP diagnostics."""
92
+ floor = SEVERITY_ORDER[settings.min_severity]
93
+ seen: set[tuple[int, str, str]] = set()
94
+ groups: dict[str, list[tuple]] = {}
95
+ found = sorted(_run_robocop(text, suffix, settings), key=lambda d: (d.range.start.line, d.range.start.character))
96
+ for diag in found:
97
+ rule_id = diag.rule.rule_id
98
+ severity = _severity_name(diag.severity)
99
+ # Promote only Info rules one command fixes; each promoted finding costs agents an edit turn.
100
+ if severity == "info" and rule_id in FIX_COMMANDS:
101
+ severity = "warning"
102
+ if SEVERITY_ORDER[severity] < floor:
103
+ continue
104
+ line = max(diag.range.start.line - 1, 0)
105
+ key = (line, rule_id, diag.message)
106
+ if key in seen:
107
+ continue
108
+ seen.add(key)
109
+ group_key = rule_id if settings.group else f"{rule_id}:{len(seen)}"
110
+ groups.setdefault(group_key, []).append((diag, rule_id, line, severity))
111
+ result = [_group_to_lsp(members) for members in groups.values()]
112
+ result.sort(key=lambda d: (d.range.start.line, d.range.start.character))
113
+ if len(result) <= settings.max_diagnostics:
114
+ return result
115
+ return result[: settings.max_diagnostics] + [_overflow(len(result) - settings.max_diagnostics, settings)]
116
+
117
+
118
+ def _overflow(hidden: int, settings: Settings) -> types.Diagnostic:
119
+ # Without it an agent can't tell a clean tail from a truncated one.
120
+ return types.Diagnostic(
121
+ range=types.Range(types.Position(0, 0), types.Position(0, 0)),
122
+ message=f"overflow {hidden} more findings not shown (maxDiagnostics {settings.max_diagnostics})",
123
+ severity=types.DiagnosticSeverity.Information,
124
+ code="overflow",
125
+ source="rflint",
126
+ )
127
+
128
+
129
+ def _group_to_lsp(members: list[tuple]) -> types.Diagnostic:
130
+ """One diagnostic per rule, anchored at its first finding, with the worst severity."""
131
+ diag, rule_id, line, _ = members[0]
132
+ severity = max((m[3] for m in members), key=SEVERITY_ORDER.__getitem__)
133
+ count = ""
134
+ if len(members) > 1:
135
+ lines = [str(m[2] + 1) for m in members[:MAX_GROUP_LINES]]
136
+ more = ",..." if len(members) > MAX_GROUP_LINES else ""
137
+ count = f"x{len(members)} (lines {','.join(lines)}{more}) "
138
+ return _to_lsp(diag, rule_id, line, severity, count)
139
+
140
+
141
+ def _to_lsp(diag, rule_id: str, line: int, severity: str, count: str = "") -> types.Diagnostic:
142
+ start_col = max(diag.range.start.character - 1, 0)
143
+ end_line = max(diag.range.end.line - 1, line)
144
+ end_col = max(diag.range.end.character - 1, 0)
145
+ command = FIX_COMMANDS.get(rule_id)
146
+ hint = f"fix all: {command}" if command else FIX_HINTS.get(rule_id)
147
+ suffix = f"; {hint}" if hint else ""
148
+ base = f"{rule_id} {count}{diag.message}"
149
+ room = MAX_MESSAGE - len(suffix)
150
+ if len(base) > room:
151
+ base = base[: room - 3] + "..."
152
+ message = base + suffix
153
+ return types.Diagnostic(
154
+ range=types.Range(types.Position(line, start_col), types.Position(end_line, end_col)),
155
+ message=message,
156
+ severity=LSP_SEVERITY[severity],
157
+ code=rule_id,
158
+ source="rflint",
159
+ )
160
+
161
+
162
+ class RflintServer(LanguageServer):
163
+ def __init__(self) -> None:
164
+ super().__init__("robotframework-lint-lsp", "0.1.0")
165
+ self.settings = Settings()
166
+ self.pending: dict[str, asyncio.Task] = {}
167
+ self.index = WorkspaceIndex()
168
+
169
+ def publish(self, uri: str) -> None:
170
+ doc = self.workspace.get_text_document(uri)
171
+ suffix = ".resource" if uri.endswith(".resource") else ".robot"
172
+ if len(doc.source) > MAX_LINT_BYTES: # a generated blob would hold the event loop
173
+ self.text_document_publish_diagnostics(
174
+ types.PublishDiagnosticsParams(uri=uri, version=doc.version, diagnostics=[]))
175
+ return
176
+ try:
177
+ self.index.update(uri, doc.source)
178
+ diagnostics = lint_text(doc.source, self.settings, suffix)
179
+ for diag in diagnostics: # robocop columns are code points
180
+ diag.range = self.workspace.position_codec.range_to_client_units(doc.lines, diag.range)
181
+ except Exception as exc: # parse errors must not kill the server
182
+ self.window_log_message(types.LogMessageParams(types.MessageType.Warning, f"rflint: {exc}"[:200]))
183
+ diagnostics = []
184
+ self.text_document_publish_diagnostics(
185
+ types.PublishDiagnosticsParams(uri=uri, version=doc.version, diagnostics=diagnostics)
186
+ )
187
+
188
+
189
+ server = RflintServer()
190
+ register_navigation(server)
191
+
192
+
193
+ @server.feature(types.INITIALIZE)
194
+ def on_initialize(ls: RflintServer, params: types.InitializeParams) -> None:
195
+ ls.settings = Settings.from_options(params.initialization_options)
196
+ scan_workspace(ls.index, params.root_uri)
197
+
198
+
199
+ @server.feature(types.TEXT_DOCUMENT_DID_OPEN)
200
+ def on_open(ls: RflintServer, params: types.DidOpenTextDocumentParams) -> None:
201
+ ls.publish(params.text_document.uri)
202
+
203
+
204
+ @server.feature(types.TEXT_DOCUMENT_DID_SAVE)
205
+ def on_save(ls: RflintServer, params: types.DidSaveTextDocumentParams) -> None:
206
+ ls.publish(params.text_document.uri)
207
+
208
+
209
+ @server.feature(types.TEXT_DOCUMENT_DID_CHANGE)
210
+ async def on_change(ls: RflintServer, params: types.DidChangeTextDocumentParams) -> None:
211
+ uri = params.text_document.uri
212
+ previous = ls.pending.pop(uri, None)
213
+ if previous:
214
+ previous.cancel()
215
+
216
+ async def later() -> None:
217
+ await asyncio.sleep(DEBOUNCE_SECONDS)
218
+ ls.pending.pop(uri, None)
219
+ ls.publish(uri)
220
+
221
+ ls.pending[uri] = asyncio.ensure_future(later())
222
+
223
+
224
+ @server.feature(types.TEXT_DOCUMENT_DID_CLOSE)
225
+ def on_close(ls: RflintServer, params: types.DidCloseTextDocumentParams) -> None:
226
+ pending = ls.pending.pop(params.text_document.uri, None)
227
+ if pending:
228
+ pending.cancel() # a late publish would bring back diagnostics for a closed file
229
+ ls.text_document_publish_diagnostics(types.PublishDiagnosticsParams(uri=params.text_document.uri, diagnostics=[]))
230
+
231
+
232
+ def main() -> None:
233
+ server.start_io()
234
+
235
+
236
+ if __name__ == "__main__":
237
+ main()