Skip to content

Commit 4bb29c4

Browse files
authored
feat: Improve performance of unused-variable and unused-argument rules (#1645)
1 parent 8aff795 commit 4bb29c4

File tree

7 files changed

+118
-80
lines changed

7 files changed

+118
-80
lines changed

src/robocop/linter/reports/print_issues.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import sys
34
from difflib import unified_diff
45
from enum import Enum
56
from pathlib import Path
@@ -254,6 +255,10 @@ def generate_report(self, diagnostics: Diagnostics, **kwargs) -> None:
254255
run_stats: RunStatistic | None = kwargs.get("run_stats")
255256
if run_stats and run_stats.files_count == 0:
256257
return
258+
if hasattr(sys.stdout, "reconfigure"):
259+
# Even if recent Python has it, it doesn't work for all the encoding without it
260+
sys.stdout.reconfigure(encoding="utf-8")
261+
sys.stderr.reconfigure(encoding="utf-8")
257262
if not self.config.linter.diff:
258263
if self.output_format == OutputFormat.SIMPLE:
259264
self.print_diagnostics_simple(diagnostics)

src/robocop/linter/rules/documentation.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@ def visit_File(self, node: File) -> None: # noqa: N802
177177
def check_if_docs_are_present( # TODO: could be implemented inside 'MissingDocumentationRule' class
178178
self, node: Keyword | TestCase | SettingSection, rule: Rule, extend_disablers: bool
179179
) -> None:
180+
# with single visitor: visit_Documentation + check context, at the end of block check if found
180181
# TODO indent
181182
for statement in node.body:
182183
if isinstance(statement, Documentation):

src/robocop/linter/rules/duplications.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
from robocop.linter import sonar_qube
88
from robocop.linter.rules import Rule, RuleSeverity, VisitorChecker, arguments, order, variables
99
from robocop.linter.utils.misc import (
10-
get_errors,
1110
normalize_robot_name,
1211
normalize_robot_var_name,
1312
strip_equals_from_assignment,
@@ -367,7 +366,7 @@ def visit_VariableSection(self, node) -> None: # noqa: N802
367366
self.generic_visit(node)
368367

369368
def visit_Variable(self, node) -> None: # noqa: N802
370-
if not node.name or get_errors(node):
369+
if not node.name or node.errors:
371370
return
372371
var_name = normalize_robot_name(self.replace_chars(node.name, "${}@&"))
373372
self.variables[var_name].append(node)
@@ -418,7 +417,7 @@ def visit_Arguments(self, node) -> None: # noqa: N802
418417
args.add(name)
419418

420419
def visit_Error(self, node) -> None: # noqa: N802
421-
for error in get_errors(node):
420+
for error in node.errors:
422421
if "is allowed only once" in error:
423422
self.report(
424423
self.duplicated_setting,

src/robocop/linter/rules/misc.py

Lines changed: 97 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Miscellaneous checkers"""
22

33
import ast
4+
import re
45
from dataclasses import dataclass
56
from pathlib import Path
67
from typing import TYPE_CHECKING
@@ -9,7 +10,6 @@
910
from robot.errors import VariableError
1011
from robot.parsing.model.blocks import For, TestCaseSection
1112
from robot.parsing.model.statements import Arguments, KeywordCall, Teardown
12-
from robot.utils import unescape
1313
from robot.variables.search import search_variable
1414

1515
from robocop.linter.diagnostics import Diagnostic
@@ -884,7 +884,7 @@ def visit_VariableSection(self, node): # noqa: N802
884884
if self.variables_expected_sign_type is None:
885885
return None
886886
for child in node.body:
887-
if not isinstance(child, Variable) or utils.get_errors(child):
887+
if not isinstance(child, Variable) or child.errors:
888888
continue
889889
var_token = child.get_token(Token.VARIABLE)
890890
self.check_assign_type(
@@ -940,7 +940,7 @@ def visit_KeywordSection(self, node) -> None: # noqa: N802
940940
visit_TestCaseSection = visit_KeywordSection # noqa: N815
941941

942942
def visit_Variable(self, node) -> None: # noqa: N802
943-
if utils.get_errors(node):
943+
if node.errors:
944944
return
945945
if not node.value: # catch variable declaration without any value
946946
self.report(self.empty_variable, node=node, end_col=node.end_col_offset)
@@ -1008,7 +1008,7 @@ class IfChecker(VisitorChecker):
10081008
multiline_inline_if: MultilineInlineIfRule
10091009

10101010
def visit_TestCase(self, node) -> None: # noqa: N802
1011-
if utils.get_errors(node):
1011+
if node.errors:
10121012
return
10131013
self.check_adjacent_ifs(node)
10141014

@@ -1194,7 +1194,7 @@ def __init__(self) -> None:
11941194
self.section_variables: dict[str, CachedVariable] = {}
11951195

11961196
def visit_Variable(self, node) -> None:
1197-
if utils.get_errors(node):
1197+
if node.errors:
11981198
return
11991199
var_token = node.get_token(Token.VARIABLE)
12001200
variable_match = search_variable(var_token.value, ignore_errors=True)
@@ -1209,6 +1209,9 @@ class UnusedVariablesChecker(VisitorChecker):
12091209
argument_overwritten_before_usage: arguments.ArgumentOverwrittenBeforeUsageRule
12101210
variable_overwritten_before_usage: variables.VariableOverwrittenBeforeUsageRule
12111211

1212+
_ESCAPED_VAR_PATTERN = re.compile(r"\$([A-Za-z_]\w*)")
1213+
_VARIABLE_NAME_PATTERN = re.compile(r"\w+")
1214+
12121215
def __init__(self) -> None:
12131216
self.arguments: dict[str, CachedVariable] = {}
12141217
self.variables: list[dict[str, CachedVariable]] = [
@@ -1301,20 +1304,24 @@ def add_argument(self, argument, normalized_name, token) -> None:
13011304

13021305
def parse_arguments(self, node) -> None:
13031306
"""Store arguments from [Arguments]. Ignore @{args} and &{kwargs}, strip default values."""
1304-
if utils.get_errors(node):
1307+
if node.errors:
13051308
return
13061309
for arg in node.get_tokens(Token.ARGUMENT):
13071310
if arg.value[0] in ("@", "&"): # ignore *args and &kwargs
13081311
continue
1309-
variable_match = search_variable(arg.value, ignore_errors=True)
1310-
if variable_match.after:
1311-
self.find_not_nested_variable(variable_match.after, is_var=False)
1312-
name = utils.remove_variable_type_conversion(variable_match.base)
1313-
normalized_name = utils.normalize_robot_name(name)
1314-
self.add_argument(variable_match.base, normalized_name, token=arg)
1312+
arg_name, default_value = utils.split_argument_default_value(arg.value)
1313+
if default_value:
1314+
self.find_not_nested_variable(default_value, can_be_escaped=False)
1315+
base_name = arg_name[2:-1]
1316+
name = utils.remove_variable_type_conversion(base_name)
1317+
name = utils.normalize_robot_name(name)
1318+
self.add_argument(base_name, name, token=arg)
1319+
# ${test.kws[0].msgs[${index}]} FIXME
13151320

13161321
def parse_embedded_arguments(self, name_token) -> None:
13171322
"""Store embedded arguments from keyword name. Ignore embedded variables patterns (${var:pattern})."""
1323+
if "$" not in name_token.value:
1324+
return
13181325
try:
13191326
for token in name_token.tokenize_variables():
13201327
if token.type == Token.VARIABLE:
@@ -1329,7 +1336,7 @@ def visit_If(self, node): # noqa: N802
13291336
return
13301337
self.branch_level += 1
13311338
for token in node.header.get_tokens(Token.ARGUMENT):
1332-
self.find_not_nested_variable(token.value, is_var=False)
1339+
self.find_not_nested_variable(token.value, can_be_escaped=True)
13331340
self.variables.append({})
13341341
for item in node.body:
13351342
self.visit(item)
@@ -1352,7 +1359,7 @@ def visit_If(self, node): # noqa: N802
13521359

13531360
def visit_IfBranch(self, node) -> None: # noqa: N802
13541361
for token in node.header.get_tokens(Token.ARGUMENT):
1355-
self.find_not_nested_variable(token.value, is_var=False)
1362+
self.find_not_nested_variable(token.value, can_be_escaped=True)
13561363
self.variables.append({})
13571364
for child in node.body:
13581365
self.visit(child)
@@ -1377,7 +1384,7 @@ def add_variables_from_if_to_scope(self, if_variables: dict[str, CachedVariable]
13771384

13781385
def visit_LibraryImport(self, node) -> None: # noqa: N802
13791386
for token in node.get_tokens(Token.NAME, Token.ARGUMENT):
1380-
self.find_not_nested_variable(token.value, is_var=False)
1387+
self.find_not_nested_variable(token.value, can_be_escaped=False)
13811388

13821389
visit_TestTags = visit_ForceTags = visit_Metadata = visit_DefaultTags = ( # noqa: N815
13831390
visit_Variable # noqa: N815
@@ -1425,9 +1432,9 @@ def visit_While(self, node): # noqa: N802
14251432
self.in_loop = True
14261433
self.used_in_scope.append(set())
14271434
for token in node.header.get_tokens(Token.ARGUMENT):
1428-
self.find_not_nested_variable(token.value, is_var=False)
1435+
self.find_not_nested_variable(token.value, can_be_escaped=True)
14291436
if node.limit:
1430-
self.find_not_nested_variable(node.limit, is_var=False)
1437+
self.find_not_nested_variable(node.limit, can_be_escaped=False)
14311438
self.generic_visit(node)
14321439
self.in_loop = False
14331440
self.revisit_variables_used_in_loop()
@@ -1440,7 +1447,7 @@ def visit_For(self, node): # noqa: N802
14401447
self.used_in_scope.append(set())
14411448
self.ignore_overwriting = True
14421449
for token in node.header.get_tokens(Token.ARGUMENT, "OPTION"): # Token.Option does not exist for RF3 and RF4
1443-
self.find_not_nested_variable(token.value, is_var=False)
1450+
self.find_not_nested_variable(token.value, can_be_escaped=False)
14441451
for token in node.header.get_tokens(Token.VARIABLE):
14451452
self.handle_assign_variable(token, ignore_var_conversion=False)
14461453
self.generic_visit(node)
@@ -1471,7 +1478,7 @@ def visit_Try(self, node): # noqa: N802
14711478
self.variables.append({})
14721479
# variables in EXCEPT ${error_pattern}
14731480
for token in try_branch.header.get_tokens(Token.ARGUMENT, Token.OPTION):
1474-
self.find_not_nested_variable(token.value, is_var=False)
1481+
self.find_not_nested_variable(token.value, can_be_escaped=True)
14751482
# except AS ${err}
14761483
if self.try_assign(try_branch) is not None:
14771484
error_var = try_branch.header.get_token(Token.VARIABLE)
@@ -1499,27 +1506,29 @@ def visit_Try(self, node): # noqa: N802
14991506

15001507
def visit_Group(self, node): # noqa: N802
15011508
for token in node.header.get_tokens(Token.ARGUMENT):
1502-
self.find_not_nested_variable(token.value, is_var=False)
1509+
self.find_not_nested_variable(token.value, can_be_escaped=True)
15031510
self.generic_visit(node)
15041511

15051512
def visit_KeywordCall(self, node) -> None: # noqa: N802
1506-
for token in node.get_tokens(Token.ARGUMENT, Token.KEYWORD): # argument can be used in the keyword name
1507-
self.find_not_nested_variable(token.value, is_var=False)
1513+
for token in node.get_tokens(Token.KEYWORD): # argument can be used in the keyword name
1514+
self.find_not_nested_variable(token.value, can_be_escaped=False)
1515+
for token in node.get_tokens(Token.ARGUMENT):
1516+
self.find_not_nested_variable(token.value, can_be_escaped=True)
15081517
for token in node.get_tokens(Token.ASSIGN): # we first check args, then assign for used and then overwritten
15091518
self.handle_assign_variable(token)
15101519

15111520
def visit_Var(self, node) -> None: # noqa: N802
15121521
if node.errors: # for example invalid variable definition like $var}
15131522
return
15141523
for arg in node.get_tokens(Token.ARGUMENT):
1515-
self.find_not_nested_variable(arg.value, is_var=False)
1524+
self.find_not_nested_variable(arg.value, can_be_escaped=True)
15161525
variable = node.get_token(Token.VARIABLE)
15171526
if variable and utils.is_var_scope_local(node):
15181527
self.handle_assign_variable(variable)
15191528

15201529
def visit_TemplateArguments(self, node) -> None: # noqa: N802
15211530
for argument in node.data_tokens:
1522-
self.find_not_nested_variable(argument.value, is_var=False)
1531+
self.find_not_nested_variable(argument.value, can_be_escaped=False)
15231532

15241533
def handle_assign_variable(self, token, ignore_var_conversion: bool = True) -> None:
15251534
"""
@@ -1565,48 +1574,69 @@ def handle_assign_variable(self, token, ignore_var_conversion: bool = True) -> N
15651574
variable = CachedVariable(variable_match.name, token, is_used=False)
15661575
self.variables[-1][normalized] = variable
15671576

1568-
def find_not_nested_variable(self, value, is_var) -> None:
1577+
def find_not_nested_variable(self, value: str, can_be_escaped: bool) -> None:
15691578
r"""
15701579
Find and process not nested variable.
15711580
1572-
Search `value` string until there is ${variable} without other variables inside. Unescaped escaped syntax
1573-
($var or \\${var}). If a variable does exist in assign variables or arguments, it is removed to denote it was
1574-
used.
1581+
Examples:
1582+
'${value}' -> value
1583+
${value_${nested}} -> nested
1584+
'String with ${var} and $escaped' -> var, escaped
1585+
1586+
Found variables are added to the scope.
1587+
15751588
"""
1576-
try:
1577-
variables = list(VariableMatches(value))
1578-
except VariableError: # for example, ${variable which wasn't closed properly
1579-
return
1580-
if not variables:
1581-
if is_var:
1582-
self.update_used_variables(value)
1583-
elif "$" in value:
1584-
self.find_escaped_variables(value) # $var
1585-
if r"\${" in value: # \\${var}
1586-
unescaped = unescape(value)
1587-
self.find_not_nested_variable(unescaped, is_var=False)
1588-
return
1589-
replaced, after = "", ""
1590-
for match in variables:
1591-
replaced += f"{match.before}placeholder{match.after}"
1592-
if match.before and "$" not in match.before and is_var: # ${test.kws[0].msgs[${index}]}
1593-
self.update_used_variables(match.before)
1594-
# handle ${variable}[item][${syntax}]
1595-
if match.base and match.base.startswith("{") and match.base.endswith("}"): # inline val
1596-
self.find_not_nested_variable(match.base[1:-1].strip(), is_var=False)
1589+
identifiers = set("$@&%")
1590+
n = len(value)
1591+
i = 0
1592+
full_match = False # whether string is a variable only
1593+
while True:
1594+
# find the next '{'
1595+
pos = value.find("{", i)
1596+
if pos == -1:
1597+
break
1598+
# must be preceded by an identifier char
1599+
if pos == 0 or value[pos - 1] not in identifiers:
1600+
i = pos + 1
1601+
continue
1602+
# found an identifier + '{' opening
1603+
start = pos + 1 # first char inside braces
1604+
depth = 1
1605+
j = start
1606+
1607+
while j < n:
1608+
# detect nested identifier + '{' (counts as increased nesting)
1609+
if value[j] in identifiers and j + 1 < n and value[j + 1] == "{":
1610+
depth += 1
1611+
j += 2
1612+
continue
1613+
1614+
if value[j] == "}":
1615+
depth -= 1
1616+
if depth == 0:
1617+
# call with the content inside the outermost braces
1618+
self.update_used_variables(value[start:j])
1619+
full_match = start == 2 and j == n - 1
1620+
i = j + 1
1621+
break
1622+
j += 1
15971623
else:
1598-
self.find_not_nested_variable(match.base, is_var=True)
1599-
for item in match.items:
1600-
self.find_not_nested_variable(item, is_var=False)
1601-
after = match.after
1602-
self.find_escaped_variables(replaced)
1603-
if after and "$" not in after and is_var: # ${test.kws[0].msgs[${index}]}
1604-
self.update_used_variables(after)
1624+
# no matching closing brace found
1625+
break
1626+
# no need to search further if we matched fully ('${var}')
1627+
if not can_be_escaped or full_match:
1628+
return
1629+
self.find_escaped_variables(value)
16051630

16061631
def find_escaped_variables(self, value) -> None:
16071632
"""Find all $var escaped variables in the value string and process them."""
1608-
for var in utils.find_escaped_variables(value):
1609-
self.update_used_variables(var)
1633+
# TODO: create iter_escaped_variables function
1634+
if "$" not in value:
1635+
return
1636+
for match in self._ESCAPED_VAR_PATTERN.finditer(value):
1637+
variable_name = match.group(1)
1638+
if variable_name.isidentifier():
1639+
self.update_used_variables(variable_name)
16101640

16111641
def update_used_variables(self, variable_name) -> None:
16121642
"""
@@ -1637,11 +1667,16 @@ def _set_variable_as_used(self, normalized_name: str, variable_scope: dict[str,
16371667
else:
16381668
self.search_by_tokenize(normalized_name, variable_scope)
16391669

1640-
@staticmethod
1641-
def search_by_tokenize(variable_name, variable_scope) -> list[str]:
1670+
def search_by_tokenize(self, variable_name, variable_scope) -> list[str]:
16421671
"""Search variables in string by tokenizing variable name using Python ast."""
16431672
if not variable_scope:
16441673
return []
1674+
# there is no syntax like ${var * 2}
1675+
if self._VARIABLE_NAME_PATTERN.fullmatch(variable_name):
1676+
if variable_name in variable_scope:
1677+
variable_scope[variable_name].is_used = True
1678+
return [variable_name]
1679+
return []
16451680
found = []
16461681
for name in utils.get_variables_from_string(variable_name):
16471682
if name in variable_scope:
@@ -1977,7 +2012,7 @@ def should_report_missing_type(self, var_name: str) -> bool:
19772012

19782013
def visit_Variable(self, node: Variable) -> None: # noqa: N802
19792014
"""Check variables in *** Variables *** section."""
1980-
if utils.get_errors(node):
2015+
if node.errors:
19812016
return
19822017
token = node.data_tokens[0]
19832018
if self.should_report_missing_type(token.value):
@@ -2011,6 +2046,7 @@ def visit_Var(self, node: Var) -> None: # noqa: N802
20112046

20122047
def visit_KeywordCall(self, node: KeywordCall) -> None: # noqa: N802
20132048
"""Check assignment expressions (${var} = Keyword)."""
2049+
# TODO: we already search for variable in unused var checker - we can combine
20142050
for token in node.get_tokens(Token.ASSIGN):
20152051
if self.should_report_missing_type(token.value):
20162052
var_match = search_variable(token.value, ignore_errors=True)

src/robocop/linter/rules/spacing.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
SeverityThreshold,
2828
VisitorChecker,
2929
)
30-
from robocop.linter.utils.misc import get_errors, get_section_name, str2bool, token_col
30+
from robocop.linter.utils.misc import get_section_name, str2bool, token_col
3131
from robocop.parsing.run_keywords import is_run_keyword
3232
from robocop.version_handling import INLINE_IF_SUPPORTED
3333

@@ -1340,7 +1340,7 @@ def visit_VariableSection(self, node) -> None: # noqa: N802
13401340

13411341
def visit_SettingSection(self, node) -> None: # noqa: N802
13421342
for child in node.body:
1343-
for error in get_errors(child):
1343+
for error in child.errors:
13441344
if "Non-existing setting" in error:
13451345
self.parse_error(child, error)
13461346

0 commit comments

Comments
 (0)