robotframework-lint-lsp 1.0.0__py3-none-any.whl

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.
@@ -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()
@@ -0,0 +1,101 @@
1
+ """LSP handlers for document symbols, definition, references and hover."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+ from urllib.parse import urlparse
8
+ from urllib.request import url2pathname
9
+
10
+ from lsprotocol import types
11
+
12
+ from .navigation import Location, WorkspaceIndex, document_symbols
13
+
14
+ # A huge checkout must not stall startup; files past the cap index when opened.
15
+ MAX_FILES = 2000
16
+ SKIP_DIRS = {".git", ".venv", "venv", "node_modules", "__pycache__", "results"}
17
+ SUFFIXES = {".robot", ".resource"}
18
+ # A generated or binary file posing as .robot must not fill memory; open files still lint.
19
+ MAX_BYTES = 1_000_000
20
+ SYMBOL_KIND = {"test": types.SymbolKind.Method, "keyword": types.SymbolKind.Function,
21
+ "variable": types.SymbolKind.Variable}
22
+
23
+
24
+ def _robot_files(root: Path):
25
+ """Prunes skipped dirs before descending and never follows links out of the tree."""
26
+ stack = [root]
27
+ while stack:
28
+ try:
29
+ entries = list(os.scandir(stack.pop()))
30
+ except OSError:
31
+ continue
32
+ for entry in entries:
33
+ if entry.is_symlink():
34
+ continue
35
+ if entry.is_dir() and entry.name not in SKIP_DIRS:
36
+ stack.append(Path(entry.path))
37
+ elif entry.is_file() and Path(entry.name).suffix in SUFFIXES and entry.stat().st_size <= MAX_BYTES:
38
+ yield Path(entry.path)
39
+
40
+
41
+ def scan_workspace(index: WorkspaceIndex, root_uri: str | None) -> None:
42
+ if not root_uri or not root_uri.startswith("file:"):
43
+ return
44
+ root = Path(url2pathname(urlparse(root_uri).path))
45
+ for count, path in enumerate(_robot_files(root)):
46
+ if count >= MAX_FILES:
47
+ return
48
+ try:
49
+ index.update(path.as_uri(), path.read_text(encoding="utf-8", errors="replace"))
50
+ except Exception: # one unparsable file must not stop the scan
51
+ continue
52
+
53
+
54
+ def _range(line: int, start: int, end: int) -> types.Range:
55
+ return types.Range(types.Position(line, start), types.Position(line, end))
56
+
57
+
58
+ def _location(ls, loc: Location) -> types.Location:
59
+ """Index columns are code points; the client counts in the negotiated encoding."""
60
+ codec = ls.workspace.position_codec
61
+ client_range = codec.range_to_client_units(ls.index.lines(loc.uri), _range(loc.line, loc.start, loc.end))
62
+ return types.Location(uri=loc.uri, range=client_range)
63
+
64
+
65
+ def _at(ls, params) -> tuple[str, int, int]:
66
+ uri = params.text_document.uri
67
+ position = ls.workspace.position_codec.position_from_client_units(ls.index.lines(uri), params.position)
68
+ return uri, position.line, position.character
69
+
70
+
71
+ def register(server) -> None:
72
+ @server.feature(types.TEXT_DOCUMENT_DOCUMENT_SYMBOL)
73
+ def on_symbols(ls, params: types.DocumentSymbolParams):
74
+ doc = ls.workspace.get_text_document(params.text_document.uri)
75
+ suffix = ".resource" if doc.uri.endswith(".resource") else ".robot"
76
+ return [types.DocumentSymbol(name=s.name, kind=SYMBOL_KIND[s.kind],
77
+ range=types.Range(types.Position(s.line, 0), types.Position(s.end_line, 0)),
78
+ selection_range=_range(s.line, 0, 0))
79
+ for s in document_symbols(doc.source, suffix)]
80
+
81
+ @server.feature(types.TEXT_DOCUMENT_DEFINITION)
82
+ def on_definition(ls, params: types.DefinitionParams):
83
+ found = ls.index.definition(*_at(ls, params))
84
+ return _location(ls, found) if found else None
85
+
86
+ @server.feature(types.TEXT_DOCUMENT_REFERENCES)
87
+ def on_references(ls, params: types.ReferenceParams):
88
+ found = ls.index.references(*_at(ls, params))
89
+ return [_location(ls, loc) for loc in found]
90
+
91
+ @server.feature(types.WORKSPACE_SYMBOL)
92
+ def on_workspace_symbol(ls, params: types.WorkspaceSymbolParams):
93
+ return [types.WorkspaceSymbol(name=f.name, kind=types.SymbolKind.Function, location=_location(ls, f.location))
94
+ for f in ls.index.find(params.query)]
95
+
96
+ @server.feature(types.TEXT_DOCUMENT_HOVER)
97
+ def on_hover(ls, params: types.HoverParams):
98
+ text = ls.index.hover(*_at(ls, params))
99
+ if text is None:
100
+ return None
101
+ return types.Hover(contents=types.MarkupContent(kind=types.MarkupKind.PlainText, value=text))
@@ -0,0 +1,188 @@
1
+ """Workspace index for document symbols, definition, references and hover.
2
+
3
+ Pure functions over buffer text so the LSP layer stays thin. Positions are
4
+ 0-based lines and columns, as LSP sends them.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import io
10
+ from dataclasses import dataclass, field
11
+
12
+ from robot.api import Token, get_model, get_resource_model
13
+ from robot.parsing.model.blocks import Keyword, TestCase
14
+ from robot.parsing.model.statements import Arguments, Documentation, Variable
15
+ from robot.utils import normalize
16
+
17
+ MAX_HOVER = 200
18
+ MAX_RESULTS = 50
19
+ # Fixture settings name a keyword in a NAME token instead of a KEYWORD token.
20
+ FIXTURES = {Token.SETUP, Token.TEARDOWN, Token.SUITE_SETUP, Token.SUITE_TEARDOWN,
21
+ Token.TEST_SETUP, Token.TEST_TEARDOWN}
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Symbol:
26
+ name: str
27
+ kind: str
28
+ line: int
29
+ end_line: int
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class Location:
34
+ uri: str
35
+ line: int
36
+ start: int
37
+ end: int
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class Found:
42
+ name: str
43
+ location: Location
44
+
45
+
46
+ @dataclass
47
+ class _Definition:
48
+ name: str
49
+ location: Location
50
+ signature: str
51
+ doc: str
52
+
53
+
54
+ def _key(name: str) -> str:
55
+ return normalize(name, ignore="_")
56
+
57
+
58
+ def _parse(text: str, suffix: str):
59
+ parser = get_resource_model if suffix == ".resource" else get_model
60
+ return parser(io.StringIO(text))
61
+
62
+
63
+ def _walk(node):
64
+ yield node
65
+ for child in getattr(node, "body", []) + getattr(node, "sections", []):
66
+ yield from _walk(child)
67
+
68
+
69
+ def document_symbols(text: str, suffix: str) -> list[Symbol]:
70
+ """Tests, keywords and variables in file order."""
71
+ symbols = []
72
+ for node in _walk(_parse(text, suffix)):
73
+ if isinstance(node, (TestCase, Keyword)):
74
+ kind = "test" if isinstance(node, TestCase) else "keyword"
75
+ symbols.append(Symbol(node.name, kind, node.lineno - 1, node.end_lineno - 1))
76
+ elif isinstance(node, Variable) and node.name:
77
+ symbols.append(Symbol(node.name, "variable", node.lineno - 1, node.lineno - 1))
78
+ return symbols[:MAX_RESULTS]
79
+
80
+
81
+ def _calls(model) -> list[Token]:
82
+ """Every token that names a called keyword."""
83
+ calls = []
84
+ for node in _walk(model):
85
+ if getattr(node, "type", None) in FIXTURES:
86
+ calls.extend(t for t in node.tokens if t.type == Token.NAME and t.value.upper() != "NONE")
87
+ elif hasattr(node, "tokens"):
88
+ calls.extend(t for t in node.tokens if t.type == Token.KEYWORD)
89
+ return calls
90
+
91
+
92
+ def _definitions(uri: str, model) -> dict[str, _Definition]:
93
+ found = {}
94
+ for node in _walk(model):
95
+ if isinstance(node, Keyword):
96
+ name = node.header.get_token(Token.KEYWORD_NAME)
97
+ loc = Location(uri, name.lineno - 1, name.col_offset, name.end_col_offset)
98
+ found[_key(node.name)] = _Definition(node.name, loc, _signature(node), _first_doc_line(node))
99
+ return found
100
+
101
+
102
+ def _signature(node: Keyword) -> str:
103
+ args = next((s.values for s in node.body if isinstance(s, Arguments)), ())
104
+ return f"{node.name}({', '.join(args)})"
105
+
106
+
107
+ def _first_doc_line(node: Keyword) -> str:
108
+ doc = next((s.value for s in node.body if isinstance(s, Documentation)), "")
109
+ return doc.split("\n", 1)[0]
110
+
111
+
112
+ @dataclass
113
+ class _FileIndex:
114
+ calls: list[Location] = field(default_factory=list)
115
+ names: list[str] = field(default_factory=list)
116
+ definitions: dict[str, _Definition] = field(default_factory=dict)
117
+ lines: list[str] = field(default_factory=list)
118
+
119
+
120
+ class WorkspaceIndex:
121
+ """Keyword definitions and calls across every indexed file."""
122
+
123
+ def __init__(self) -> None:
124
+ self.files: dict[str, _FileIndex] = {}
125
+
126
+ def update(self, uri: str, text: str) -> None:
127
+ model = _parse(text, ".resource" if uri.endswith(".resource") else ".robot")
128
+ tokens = _calls(model)
129
+ self.files[uri] = _FileIndex(
130
+ [Location(uri, t.lineno - 1, t.col_offset, t.end_col_offset) for t in tokens],
131
+ [_key(t.value) for t in tokens],
132
+ _definitions(uri, model),
133
+ text.splitlines(keepends=True),
134
+ )
135
+
136
+ def lines(self, uri: str) -> list[str]:
137
+ """Source lines, so the LSP layer can convert columns to the client's encoding."""
138
+ index = self.files.get(uri)
139
+ return index.lines if index else []
140
+
141
+ def _name_at(self, uri: str, line: int, character: int) -> str | None:
142
+ index = self.files.get(uri)
143
+ if index is None:
144
+ return None
145
+ for loc, name in zip(index.calls, index.names):
146
+ if loc.line == line and loc.start <= character <= loc.end:
147
+ return name
148
+ return next((k for k, d in index.definitions.items()
149
+ if d.location.line == line and d.location.start <= character <= d.location.end), None)
150
+
151
+ def _lookup(self, uri: str, name: str) -> _Definition | None:
152
+ local = self.files[uri].definitions.get(name)
153
+ if local:
154
+ return local
155
+ return next((f.definitions[name] for f in self.files.values() if name in f.definitions), None)
156
+
157
+ def _resolve(self, uri: str, line: int, character: int) -> _Definition | None:
158
+ name = self._name_at(uri, line, character)
159
+ return self._lookup(uri, name) if name else None
160
+
161
+ def definition(self, uri: str, line: int, character: int) -> Location | None:
162
+ found = self._resolve(uri, line, character)
163
+ return found.location if found else None
164
+
165
+ def references(self, uri: str, line: int, character: int) -> list[Location]:
166
+ name = self._name_at(uri, line, character)
167
+ if name is None:
168
+ return []
169
+ found = [loc for f in self.files.values() for loc, n in zip(f.calls, f.names) if n == name]
170
+ return found[:MAX_RESULTS]
171
+
172
+ def find(self, query: str) -> list[Found]:
173
+ """Keywords whose name matches the query: exact, then prefix, then substring."""
174
+ wanted = _key(query)
175
+ ranked = []
176
+ for f in self.files.values():
177
+ for key, d in f.definitions.items():
178
+ if wanted in key:
179
+ rank = 0 if key == wanted else 1 if key.startswith(wanted) else 2
180
+ ranked.append((rank, d.name, Found(d.name, d.location)))
181
+ return [found for _, _, found in sorted(ranked, key=lambda r: r[:2])][:MAX_RESULTS]
182
+
183
+ def hover(self, uri: str, line: int, character: int) -> str | None:
184
+ found = self._resolve(uri, line, character)
185
+ if found is None:
186
+ return None
187
+ text = f"{found.signature}\n{found.doc}" if found.doc else found.signature
188
+ return text[:MAX_HOVER]
@@ -0,0 +1 @@
1
+ """Custom Robocop rules for Robot Framework 7.5."""
@@ -0,0 +1,78 @@
1
+ """Rules for keyword call arguments."""
2
+
3
+ import inspect
4
+
5
+ from robot.libraries.BuiltIn import BuiltIn
6
+ from robot.libraries.Collections import Collections
7
+ from robot.utils import normalize
8
+
9
+ from robocop.linter.rules import Rule, RuleSeverity, VisitorChecker
10
+
11
+ DEPRECATED_NAMES = ("list_", "time_")
12
+
13
+
14
+ def keywords_with_deprecated_args() -> dict[str, frozenset[str]]:
15
+ """Map normalized keyword name to its deprecated argument names, read from RF."""
16
+ found = {}
17
+ for library in (BuiltIn, Collections):
18
+ for attr, method in inspect.getmembers(library, inspect.isfunction):
19
+ if attr.startswith("_"):
20
+ continue
21
+ params = set(inspect.signature(method).parameters) & set(DEPRECATED_NAMES)
22
+ if not params:
23
+ continue
24
+ frozen = frozenset(params)
25
+ found[normalize(attr, ignore="_")] = frozen
26
+ found[normalize(f"{library.__name__}.{attr}", ignore="_")] = frozen
27
+ return found
28
+
29
+
30
+ KEYWORDS = keywords_with_deprecated_args()
31
+
32
+
33
+ class DeprecatedArgumentNameRule(Rule):
34
+ """
35
+ Deprecated argument name in a Collections or BuiltIn keyword call.
36
+
37
+ The rf-7.5 release notes, section "Deprecated features", say:
38
+ "`list_` argument used in Collections and `time_` argument used in Builtin
39
+ have been deprecated (`#5763`_). They will be renamed to `list` and `time`,
40
+ respectively, in Robot Framework 8.0. This only affects usages like
41
+ `time_=1 second`, not usages where values are passed positionally like
42
+ `1 second`." Keyword signatures come from Robot Framework's own library
43
+ classes. Source:
44
+ https://github.com/robotframework/robotframework/blob/master/doc/releasenotes/rf-7.5.rst
45
+
46
+ Incorrect code example:
47
+
48
+ Sleep time_=1 second
49
+
50
+ Correct code:
51
+
52
+ Sleep 1 second
53
+ """
54
+
55
+ name = "deprecated-argument-name"
56
+ rule_id = "RFL006"
57
+ message = "'{arg}' is deprecated since RF 7.5; pass it positionally"
58
+ severity = RuleSeverity.WARNING
59
+ added_in_version = "0.1.0"
60
+
61
+
62
+ class ArgumentNameChecker(VisitorChecker):
63
+ deprecated_argument_name: DeprecatedArgumentNameRule
64
+
65
+ def visit_KeywordCall(self, node): # noqa: N802 - robot visitor naming
66
+ names = KEYWORDS.get(normalize(node.keyword or "", ignore="_"))
67
+ if not names:
68
+ return
69
+ for token in node.get_tokens("ARGUMENT"):
70
+ arg = token.value.split("=", 1)[0]
71
+ if "=" in token.value and arg in names:
72
+ self.report(
73
+ self.deprecated_argument_name,
74
+ arg=arg,
75
+ lineno=token.lineno,
76
+ col=token.col_offset + 1,
77
+ end_col=token.end_col_offset + 1,
78
+ )
@@ -0,0 +1,78 @@
1
+ """Rules for keyword documentation."""
2
+
3
+ from robot.running import docstringparser
4
+
5
+ from robocop.linter.rules import Rule, RuleSeverity, VisitorChecker
6
+
7
+
8
+ class _WarningRecorder:
9
+ def __init__(self):
10
+ self.warnings = []
11
+
12
+ def warn(self, message):
13
+ self.warnings.append(message)
14
+
15
+
16
+ def has_tags_without_empty_row(doc: str) -> bool:
17
+ """Ask Robot Framework's own docstring parser whether it warns about Tags:."""
18
+ recorder = _WarningRecorder()
19
+ original = docstringparser.LOGGER
20
+ docstringparser.LOGGER = recorder
21
+ try:
22
+ docstringparser.parse_docstring(doc)
23
+ finally:
24
+ docstringparser.LOGGER = original
25
+ return any("before 'Tags:'" in message for message in recorder.warnings)
26
+
27
+
28
+ class DocTagsWithoutEmptyRowRule(Rule):
29
+ """
30
+ Tags header in documentation without an empty row before it.
31
+
32
+ The rf-7.5 release notes, section "Deprecated features", say:
33
+ "When specifying tags as part of documentation, omitting an empty row
34
+ before the `Tags:` header is deprecated (`#5707`_)." Detection uses
35
+ Robot Framework's own ``parse_docstring``. Source:
36
+ https://github.com/robotframework/robotframework/blob/master/doc/releasenotes/rf-7.5.rst
37
+
38
+ Incorrect code example:
39
+
40
+ [Documentation] Does a thing.
41
+ ... Tags: smoke
42
+
43
+ Correct code:
44
+
45
+ [Documentation] Does a thing.
46
+ ...
47
+ ... Tags: smoke
48
+ """
49
+
50
+ name = "doc-tags-without-empty-row"
51
+ rule_id = "RFL005"
52
+ message = "No empty row before 'Tags:' is deprecated since RF 7.5"
53
+ severity = RuleSeverity.WARNING
54
+ added_in_version = "0.1.0"
55
+
56
+
57
+ class DocTagsChecker(VisitorChecker):
58
+ doc_tags_without_empty_row: DocTagsWithoutEmptyRowRule
59
+
60
+ def visit_Keyword(self, node): # noqa: N802 - robot visitor naming
61
+ for statement in node.body:
62
+ if getattr(statement, "type", None) == "DOCUMENTATION":
63
+ self.check_documentation(statement)
64
+
65
+ def check_documentation(self, node):
66
+ if not has_tags_without_empty_row(node.value):
67
+ return
68
+ tokens = node.get_tokens("ARGUMENT")
69
+ target = next(
70
+ (t for t in reversed(tokens) if t.value.strip().lower().startswith("tags:")),
71
+ node.data_tokens[0],
72
+ )
73
+ self.report(
74
+ self.doc_tags_without_empty_row,
75
+ lineno=target.lineno,
76
+ col=target.col_offset + 1,
77
+ end_col=target.end_col_offset + 1,
78
+ )
@@ -0,0 +1,55 @@
1
+ """Rules for FOR loops."""
2
+
3
+ from robot.api import Token
4
+
5
+ from robocop.linter.rules import Rule, RuleSeverity, VisitorChecker
6
+
7
+ RF_70_NOTES = (
8
+ "https://github.com/robotframework/robotframework/blob/master/doc/releasenotes/rf-7.0.rst"
9
+ )
10
+
11
+
12
+ class ForInZipWithoutModeRule(Rule):
13
+ """
14
+ FOR IN ZIP without an explicit ``mode``.
15
+
16
+ Robot Framework 7.0 added ``mode=STRICT``, ``mode=SHORTEST`` and
17
+ ``mode=LONGEST`` to FOR IN ZIP. Lists of different lengths are planned to
18
+ fail in a later release, so the release notes advise setting ``mode``
19
+ explicitly. Source: rf-7.0 release notes,
20
+ https://github.com/robotframework/robotframework/blob/master/doc/releasenotes/rf-7.0.rst
21
+
22
+ Incorrect code example:
23
+
24
+ FOR ${a} ${b} IN ZIP ${LIST_A} ${LIST_B}
25
+ Log ${a} ${b}
26
+ END
27
+
28
+ Correct code:
29
+
30
+ FOR ${a} ${b} IN ZIP ${LIST_A} ${LIST_B} mode=STRICT
31
+ Log ${a} ${b}
32
+ END
33
+ """
34
+
35
+ name = "for-in-zip-without-mode"
36
+ rule_id = "RFL001"
37
+ message = "FOR IN ZIP without mode=; set STRICT, SHORTEST or LONGEST"
38
+ severity = RuleSeverity.WARNING
39
+ added_in_version = "0.1.0"
40
+
41
+
42
+ class ForLoopChecker(VisitorChecker):
43
+ for_in_zip_without_mode: ForInZipWithoutModeRule
44
+
45
+ def visit_For(self, node): # noqa: N802 - robot visitor naming
46
+ if node.flavor == "IN ZIP" and not node.mode:
47
+ header = node.header
48
+ separator = header.get_token(Token.FOR_SEPARATOR)
49
+ self.report(
50
+ self.for_in_zip_without_mode,
51
+ node=header,
52
+ col=separator.col_offset + 1,
53
+ end_col=separator.end_col_offset + 1,
54
+ )
55
+ self.generic_visit(node)
@@ -0,0 +1,55 @@
1
+ """Rules for library imports."""
2
+
3
+ from robocop.linter.rules import Rule, RuleSeverity, VisitorChecker
4
+
5
+ RF_75_NOTES = (
6
+ "https://github.com/robotframework/robotframework/blob/master/doc/releasenotes/rf-7.5.rst"
7
+ )
8
+
9
+ # Standard libraries deprecated in RF 7.5, mapped to the replacement named there.
10
+ DEPRECATED_LIBRARIES = {"Telnet": "TelnetLibrary"}
11
+
12
+
13
+ class DeprecatedStandardLibraryRule(Rule):
14
+ """
15
+ Import of a standard library deprecated in Robot Framework 7.5.
16
+
17
+ The rf-7.5 release notes, section "Deprecated features", say: "The Telnet
18
+ library has been deprecated (#5786). The new external TelnetLibrary
19
+ should be used instead." Telnet is the only standard library that section
20
+ deprecates. Source: rf-7.5 release notes,
21
+ https://github.com/robotframework/robotframework/blob/master/doc/releasenotes/rf-7.5.rst
22
+
23
+ Incorrect code example:
24
+
25
+ *** Settings ***
26
+ Library Telnet
27
+
28
+ Correct code:
29
+
30
+ *** Settings ***
31
+ Library TelnetLibrary
32
+ """
33
+
34
+ name = "deprecated-standard-library"
35
+ rule_id = "RFL002"
36
+ message = "{name} is deprecated since RF 7.5; use {replacement}"
37
+ severity = RuleSeverity.WARNING
38
+ added_in_version = "0.1.0"
39
+
40
+
41
+ class ImportChecker(VisitorChecker):
42
+ deprecated_standard_library: DeprecatedStandardLibraryRule
43
+
44
+ def visit_LibraryImport(self, node): # noqa: N802 - robot visitor naming
45
+ replacement = DEPRECATED_LIBRARIES.get(node.name)
46
+ if replacement:
47
+ token = node.get_token("NAME")
48
+ self.report(
49
+ self.deprecated_standard_library,
50
+ name=node.name,
51
+ replacement=replacement,
52
+ node=node,
53
+ col=token.col_offset + 1,
54
+ end_col=token.end_col_offset + 1,
55
+ )
@@ -0,0 +1,88 @@
1
+ """Rules for tag patterns."""
2
+
3
+ import re
4
+ import warnings
5
+
6
+ from robot.api import Token
7
+ from robot.model.tags import TagPatterns
8
+
9
+ from robocop.linter.rules import Rule, RuleSeverity, VisitorChecker
10
+
11
+ # Operator named in the Robot Framework 7.5 TagPatterns deprecation warning.
12
+ WARNED_OPERATOR = re.compile(r"'(AND|OR|NOT)' is currently considered")
13
+ # Keywords whose arguments are tag patterns.
14
+ PATTERN_KEYWORDS = {"removetags"}
15
+
16
+
17
+ def deprecated_form(pattern: str) -> str | None:
18
+ if "&" in pattern:
19
+ return "&"
20
+ with warnings.catch_warnings(record=True) as caught:
21
+ warnings.simplefilter("always")
22
+ TagPatterns([pattern])
23
+ for warning in caught:
24
+ match = WARNED_OPERATOR.search(str(warning.message))
25
+ if match:
26
+ return match.group(1)
27
+ return None
28
+
29
+
30
+ class DeprecatedTagPatternRule(Rule):
31
+ """
32
+ Tag pattern form deprecated in Robot Framework 7.5.
33
+
34
+ The rf-7.5 release notes, section "Deprecated features", say:
35
+ "Boolean operators used with tag patterns need to be separated from tags
36
+ more clearly and usages like `XORY` are deprecated (`#5657`_). Operators
37
+ can be surrounded with spaces like `X OR Y` or tags can be specified in
38
+ lower case like `xORy`." and "Using `&` as a Boolean operator with tag
39
+ patterns is deprecated and `AND` should be used instead (`#5661`_)."
40
+ Checked: ``-tag`` removals in Test Tags, Keyword Tags, Force Tags,
41
+ Default Tags and [Tags], and Remove Tags arguments. Source:
42
+ https://github.com/robotframework/robotframework/blob/master/doc/releasenotes/rf-7.5.rst
43
+
44
+ Incorrect code example:
45
+
46
+ [Tags] -smoke&slow
47
+ Remove Tags SMOKEORSLOW
48
+
49
+ Correct code:
50
+
51
+ [Tags] -smoke AND slow
52
+ Remove Tags SMOKE OR SLOW
53
+ """
54
+
55
+ name = "deprecated-tag-pattern"
56
+ rule_id = "RFL003"
57
+ message = "'{operator}' tag pattern is deprecated since RF 7.5; use spaced AND/OR/NOT"
58
+ severity = RuleSeverity.WARNING
59
+ added_in_version = "0.1.0"
60
+
61
+
62
+ class TagPatternChecker(VisitorChecker):
63
+ deprecated_tag_pattern: DeprecatedTagPatternRule
64
+
65
+ def check_token(self, token, pattern):
66
+ operator = deprecated_form(pattern)
67
+ if operator:
68
+ self.report(
69
+ self.deprecated_tag_pattern,
70
+ operator=operator,
71
+ lineno=token.lineno,
72
+ col=token.col_offset + 1,
73
+ end_col=token.end_col_offset + 1,
74
+ )
75
+
76
+ def check_tag_removals(self, node):
77
+ for token in node.get_tokens(Token.ARGUMENT):
78
+ if token.value.startswith("-"):
79
+ self.check_token(token, token.value[1:])
80
+
81
+ visit_TestTags = visit_KeywordTags = visit_ForceTags = check_tag_removals # noqa: N815
82
+ visit_DefaultTags = visit_Tags = check_tag_removals # noqa: N815
83
+
84
+ def visit_KeywordCall(self, node): # noqa: N802 - robot visitor naming
85
+ name = (node.keyword or "").split(".")[-1].replace(" ", "").replace("_", "").lower()
86
+ if name in PATTERN_KEYWORDS:
87
+ for token in node.get_tokens(Token.ARGUMENT):
88
+ self.check_token(token, token.value)
@@ -0,0 +1,75 @@
1
+ """Rules for localized section headers and settings."""
2
+
3
+ from robot.api import Token
4
+ from robot.conf.languages import Language
5
+
6
+ from robocop.linter.rules import Rule, RuleSeverity, VisitorChecker
7
+
8
+ MARKER_TYPES = frozenset(Token.HEADER_TOKENS) | frozenset(Token.SETTING_TOKENS)
9
+
10
+
11
+ def normalize(marker: str) -> str:
12
+ return " ".join(marker.strip("*[] ").split()).title()
13
+
14
+
15
+ def deprecated_markers() -> dict[str, tuple[str, str]]:
16
+ """Map old term to (new term, version) from Robot Framework's own languages."""
17
+ found = {}
18
+ for language in Language.__subclasses__():
19
+ for old, (new, version) in language.deprecations.items():
20
+ found[normalize(old)] = (new, version or "")
21
+ return found
22
+
23
+
24
+ DEPRECATED = deprecated_markers()
25
+
26
+
27
+ class DeprecatedTranslationRule(Rule):
28
+ """
29
+ Translation deprecated in Robot Framework 7.5.
30
+
31
+ The rf-7.5 release notes, section "Deprecated features", say:
32
+ "French "Test Cases" translation "Unites de test" has been deprecated in
33
+ favor of "Cas de test" (`#5510`_)." and "Finnish "Tags" translation
34
+ "Tagit" has been deprecated and "Tunnisteet" should be used instead
35
+ (`#5726`_)." (the French term carries an accented e). Terms come from
36
+ Robot Framework's own ``Language.deprecations``. A term is only reported
37
+ when Robot Framework parsed it as a header or setting, so only files
38
+ whose language is set to French or Finnish. Source:
39
+ https://github.com/robotframework/robotframework/blob/master/doc/releasenotes/rf-7.5.rst
40
+
41
+ Incorrect code example (``language: fi``):
42
+
43
+ [Tagit] smoke
44
+
45
+ Correct code:
46
+
47
+ [Tunnisteet] smoke
48
+ """
49
+
50
+ name = "deprecated-translation"
51
+ rule_id = "RFL004"
52
+ message = "'{old}' is deprecated since RF {version}; use '{new}'"
53
+ severity = RuleSeverity.WARNING
54
+ added_in_version = "0.1.0"
55
+
56
+
57
+ class TranslationChecker(VisitorChecker):
58
+ deprecated_translation: DeprecatedTranslationRule
59
+
60
+ def visit_Statement(self, node): # noqa: N802 - robot visitor naming
61
+ token = node.data_tokens[0] if node.data_tokens else None
62
+ if token is None or token.type not in MARKER_TYPES:
63
+ return
64
+ old = normalize(token.value)
65
+ if old in DEPRECATED:
66
+ new, version = DEPRECATED[old]
67
+ self.report(
68
+ self.deprecated_translation,
69
+ old=" ".join(token.value.strip("*[] ").split()),
70
+ new=new,
71
+ version=version,
72
+ lineno=token.lineno,
73
+ col=token.col_offset + 1,
74
+ end_col=token.end_col_offset + 1,
75
+ )
@@ -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,19 @@
1
+ robotframework_lint/__init__.py,sha256=TKAkEyJwR1cQHKseS2EUxluKGzs6R0axCN9MRHGDcC8,68
2
+ robotframework_lint/autofix.py,sha256=W53eP3AIYfqiDRevZNZwscet10pg5LEDa68YPlqSg80,2709
3
+ robotframework_lint/hints.py,sha256=MFVDhTTMB3ReMP0s5yMmo8efCbT6ETxKA7OiXfcH2bY,1090
4
+ robotframework_lint/lsp.py,sha256=pfZLH8so-Igqpgc6ZTL9hJkykqZYAtTHvqZ8MEnhkos,9618
5
+ robotframework_lint/lsp_navigation.py,sha256=0bmBo_S8tjRstSk_Fot0tP_8Q5wg68k0P9KNNY2bNtM,4285
6
+ robotframework_lint/navigation.py,sha256=FV96jr8ZOkSXKt7n4vJcageBMXU37pY-YdXuGkheZhw,6601
7
+ robotframework_lint/rules/__init__.py,sha256=Kuwq_plVqzz0_HDw_4PJitWuAn5twXc2XJNo4oaKiP4,52
8
+ robotframework_lint/rules/arguments.py,sha256=SA2OnRTy9qGZwstgN5ZuZ23ow6YGZEv0Zs8XYYspXuU,2733
9
+ robotframework_lint/rules/documentation.py,sha256=7qZM91lfvlxtL0X22IL0nfugeBQiMdql5Gfl8bbcpq4,2452
10
+ robotframework_lint/rules/for_loops.py,sha256=2Y02bpJ1SE0aGVEe7OznpKVxSnLKW5uWesyFerqx7Qw,1735
11
+ robotframework_lint/rules/imports.py,sha256=QjJhUzpIYNNv445mxNvoCBwnZ5UGChOYbFSTZSAp9ww,1794
12
+ robotframework_lint/rules/tag_patterns.py,sha256=ORDKgTVo9JFHRINSFZHmRetRattGWPk-E6bsl9rr1wQ,3121
13
+ robotframework_lint/rules/translations.py,sha256=_MbxBK-ZnNWPya6f3SCew5UXgKx93ApIqgVTuSJffEc,2606
14
+ robotframework_lint_lsp-1.0.0.dist-info/licenses/LICENSE,sha256=Nq1bZgAWPJZM65BczwEj9JEP9_rC1-5ITtAV57GgubI,1072
15
+ robotframework_lint_lsp-1.0.0.dist-info/METADATA,sha256=3hFIaRmtDH8fRGFXZ1IQERUgio0FTIakWNbbD3BjU2c,5690
16
+ robotframework_lint_lsp-1.0.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
17
+ robotframework_lint_lsp-1.0.0.dist-info/entry_points.txt,sha256=qojLs7x0AxD16V1ZgPrzMQMQZaCep8JDKM6nyA3Lil8,203
18
+ robotframework_lint_lsp-1.0.0.dist-info/top_level.txt,sha256=Hui6NmOdNR_OIDQKL2CCiRTVlA4q76w-AjK7P5-B0tQ,20
19
+ robotframework_lint_lsp-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,6 @@
1
+ [console_scripts]
2
+ robotframework-lint-autofix = robotframework_lint.autofix:main
3
+ robotframework-lint-lsp = robotframework_lint.lsp:main
4
+
5
+ [robocop.plugins]
6
+ robotframework_lint = robotframework_lint.rules
@@ -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 @@
1
+ robotframework_lint