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.
- robotframework_lint_lsp-1.0.0/LICENSE +21 -0
- robotframework_lint_lsp-1.0.0/PKG-INFO +127 -0
- robotframework_lint_lsp-1.0.0/README.md +101 -0
- robotframework_lint_lsp-1.0.0/pyproject.toml +50 -0
- robotframework_lint_lsp-1.0.0/setup.cfg +4 -0
- robotframework_lint_lsp-1.0.0/src/robotframework_lint/__init__.py +3 -0
- robotframework_lint_lsp-1.0.0/src/robotframework_lint/autofix.py +63 -0
- robotframework_lint_lsp-1.0.0/src/robotframework_lint/hints.py +24 -0
- robotframework_lint_lsp-1.0.0/src/robotframework_lint/lsp.py +237 -0
- robotframework_lint_lsp-1.0.0/src/robotframework_lint/lsp_navigation.py +101 -0
- robotframework_lint_lsp-1.0.0/src/robotframework_lint/navigation.py +188 -0
- robotframework_lint_lsp-1.0.0/src/robotframework_lint/rules/__init__.py +1 -0
- robotframework_lint_lsp-1.0.0/src/robotframework_lint/rules/arguments.py +78 -0
- robotframework_lint_lsp-1.0.0/src/robotframework_lint/rules/documentation.py +78 -0
- robotframework_lint_lsp-1.0.0/src/robotframework_lint/rules/for_loops.py +55 -0
- robotframework_lint_lsp-1.0.0/src/robotframework_lint/rules/imports.py +55 -0
- robotframework_lint_lsp-1.0.0/src/robotframework_lint/rules/tag_patterns.py +88 -0
- robotframework_lint_lsp-1.0.0/src/robotframework_lint/rules/translations.py +75 -0
- robotframework_lint_lsp-1.0.0/src/robotframework_lint_lsp.egg-info/PKG-INFO +127 -0
- robotframework_lint_lsp-1.0.0/src/robotframework_lint_lsp.egg-info/SOURCES.txt +34 -0
- robotframework_lint_lsp-1.0.0/src/robotframework_lint_lsp.egg-info/dependency_links.txt +1 -0
- robotframework_lint_lsp-1.0.0/src/robotframework_lint_lsp.egg-info/entry_points.txt +6 -0
- robotframework_lint_lsp-1.0.0/src/robotframework_lint_lsp.egg-info/requires.txt +6 -0
- robotframework_lint_lsp-1.0.0/src/robotframework_lint_lsp.egg-info/top_level.txt +1 -0
- robotframework_lint_lsp-1.0.0/tests/test_arg_names.py +29 -0
- robotframework_lint_lsp-1.0.0/tests/test_autofix.py +103 -0
- robotframework_lint_lsp-1.0.0/tests/test_deprecated_library.py +30 -0
- robotframework_lint_lsp-1.0.0/tests/test_doc_tags.py +27 -0
- robotframework_lint_lsp-1.0.0/tests/test_entry_point.py +23 -0
- robotframework_lint_lsp-1.0.0/tests/test_for_in_zip_mode.py +28 -0
- robotframework_lint_lsp-1.0.0/tests/test_lsp.py +266 -0
- robotframework_lint_lsp-1.0.0/tests/test_lsp_navigation.py +101 -0
- robotframework_lint_lsp-1.0.0/tests/test_navigation.py +80 -0
- robotframework_lint_lsp-1.0.0/tests/test_plugin_config.py +22 -0
- robotframework_lint_lsp-1.0.0/tests/test_tag_pattern.py +45 -0
- 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,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()
|