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.
- robotframework_lint/__init__.py +3 -0
- robotframework_lint/autofix.py +63 -0
- robotframework_lint/hints.py +24 -0
- robotframework_lint/lsp.py +237 -0
- robotframework_lint/lsp_navigation.py +101 -0
- robotframework_lint/navigation.py +188 -0
- robotframework_lint/rules/__init__.py +1 -0
- robotframework_lint/rules/arguments.py +78 -0
- robotframework_lint/rules/documentation.py +78 -0
- robotframework_lint/rules/for_loops.py +55 -0
- robotframework_lint/rules/imports.py +55 -0
- robotframework_lint/rules/tag_patterns.py +88 -0
- robotframework_lint/rules/translations.py +75 -0
- robotframework_lint_lsp-1.0.0.dist-info/METADATA +127 -0
- robotframework_lint_lsp-1.0.0.dist-info/RECORD +19 -0
- robotframework_lint_lsp-1.0.0.dist-info/WHEEL +5 -0
- robotframework_lint_lsp-1.0.0.dist-info/entry_points.txt +6 -0
- robotframework_lint_lsp-1.0.0.dist-info/licenses/LICENSE +21 -0
- robotframework_lint_lsp-1.0.0.dist-info/top_level.txt +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,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
|