diff --git a/.github/workflows/release_check.yml b/.github/workflows/release_check.yml index 460d5f469..ac46eb268 100644 --- a/.github/workflows/release_check.yml +++ b/.github/workflows/release_check.yml @@ -20,7 +20,7 @@ jobs: performance-tests: runs-on: ubuntu-latest - if: "contains(github.event.pull_request.labels.*.name, 'autorelease: pending')" + if: "contains(github.event.pull_request.labels.*.name, 'autorelease: pending') || contains(github.event.pull_request.labels.*.name, 'performance')" steps: - uses: actions/checkout@v4 - uses: astral-sh/setup-uv@v4 diff --git a/src/robocop/__init__.py b/src/robocop/__init__.py index aec8089cc..c605d8dc7 100644 --- a/src/robocop/__init__.py +++ b/src/robocop/__init__.py @@ -1 +1 @@ -__version__ = "7.2.0" # x-release-please-version +__version__ = "7.3.0" # x-release-please-version diff --git a/src/robocop/config.py b/src/robocop/config.py index 025ddba15..5cff41a6e 100644 --- a/src/robocop/config.py +++ b/src/robocop/config.py @@ -9,21 +9,16 @@ from robot.errors import DataError -from robocop.linter.utils.misc import ROBOT_VERSION - try: from robot.api import Languages # RF 6.0 except ImportError: Languages = None -import pathspec import typer from typing_extensions import Self -from robocop import exceptions, files -from robocop.cache import RobocopCache +from robocop import exceptions from robocop.formatter import formatters from robocop.formatter.skip import SkipConfig -from robocop.formatter.utils import misc # TODO merge with linter misc from robocop.linter import rules from robocop.linter.rules import ( AfterRunChecker, @@ -32,9 +27,8 @@ RuleSeverity, ) from robocop.linter.utils.misc import compile_rule_pattern -from robocop.linter.utils.version_matching import Version +from robocop.version_handling import ROBOT_VERSION, Version -CONFIG_NAMES = frozenset(("robocop.toml", "pyproject.toml", "robot.toml")) DEFAULT_INCLUDE = frozenset(("*.robot", "*.resource")) DEFAULT_EXCLUDE = frozenset((".direnv", ".eggs", ".git", ".svn", ".hg", ".nox", ".tox", ".venv", "venv", "dist")) @@ -42,7 +36,6 @@ if TYPE_CHECKING: import re - from collections.abc import Generator from robocop.linter.rules import Rule @@ -167,10 +160,10 @@ def validate_target_version(value: str | TargetVersion | None) -> int | None: raise typer.BadParameter( f"Invalid target Robot Framework version: '{value}' is not one of {versions}" ) from None - if target_version > misc.ROBOT_VERSION.major: + if target_version > ROBOT_VERSION.major: raise typer.BadParameter( f"Target Robot Framework version ({target_version}) should not be higher than " - f"installed version ({misc.ROBOT_VERSION})." + f"installed version ({ROBOT_VERSION})." ) from None return target_version @@ -442,7 +435,7 @@ class FormatterConfig: configure: list[str] | None = field(default_factory=list) force_order: bool | None = False allow_disabled: bool | None = False - target_version: int | str | None = field(default=misc.ROBOT_VERSION.major, compare=False) + target_version: int | str | None = field(default=ROBOT_VERSION.major, compare=False) skip_config: SkipConfig = field(default_factory=SkipConfig) overwrite: bool | None = None diff: bool | None = False @@ -720,7 +713,7 @@ class Config: languages: Languages | None = field(default=None, compare=False) verbose: bool | None = field(default_factory=bool) silent: bool | None = field(default_factory=bool) - target_version: int | str | None = misc.ROBOT_VERSION.major + target_version: int | str | None = ROBOT_VERSION.major _hash: int | None = None config_source: str = "cli" @@ -892,247 +885,3 @@ def hash(self) -> str: hasher.update(language_str.encode("utf-8")) self._hash = hasher.hexdigest() return self._hash - - -class GitIgnoreResolver: - def __init__(self): - self.cached_ignores: dict[Path, list[pathspec.PathSpec] | None] = {} - self.ignore_dirs: set[Path] = set() - - def path_excluded(self, path: Path, gitignores: list[tuple[Path, pathspec.PathSpec]]) -> bool: - """Find path gitignores and check if file is excluded.""" - if not gitignores: - return False - for gitignore_path, gitignore in gitignores: - relative_path = files.get_relative_path(path, gitignore_path) - path = str(relative_path) - # fixes a bug in pathspec where directory needs to end with / to be ignored by pattern - if relative_path.is_dir() and path != ".": - path = f"{path}{os.sep}" - if gitignore.match_file(path): - return True - return False - - def read_gitignore(self, path: Path) -> pathspec.PathSpec: - """Return a PathSpec loaded from the file.""" - with path.open(encoding="utf-8") as gf: - lines = gf.readlines() - return pathspec.PathSpec.from_lines(pathspec.patterns.GitWildMatchPattern, lines) - - def resolve_path_ignores(self, path: Path) -> list[tuple[Path, pathspec.PathSpec]]: - """ - Visit all parent directories and find all gitignores. - - Gitignores are cached for multiple sources. - - Args: - path: path to file/directory - - Returns: - PathSpec from merged gitignores. - - """ - # TODO: respect nogitignore flag - if path.is_file(): - path = path.parent - gitignores = [] - search_paths = (parent for parent in [path, *path.parents]) - for parent_path in search_paths: - if parent_path in self.ignore_dirs: # dir that does not have .gitignore (marked as such) - gitignores.extend([self.cached_ignores[path] for path in search_paths if path in self.cached_ignores]) - break - if parent_path in self.cached_ignores: - gitignores.append(self.cached_ignores[parent_path]) - # if any parent is cached, we can retrieve any parent with gitignore from cache and return early - gitignores.extend([self.cached_ignores[path] for path in search_paths if path in self.cached_ignores]) - break - if (gitignore_path := parent_path / ".gitignore").is_file(): - gitignore = self.read_gitignore(gitignore_path) - self.cached_ignores[parent_path] = (parent_path, gitignore) - gitignores.append((parent_path, gitignore)) - else: - self.ignore_dirs.add(parent_path) - if (parent_path / ".git").is_dir(): - break - return gitignores - - -class ConfigManager: - """ - Finds and loads configuration files for each file. - - Config provided from cli takes priority. ``--config`` option overrides any found configuration file. - """ - - def __init__( - self, - sources: list[str] | None = None, - config: Path | None = None, - root: str | None = None, - ignore_git_dir: bool = False, - ignore_file_config: bool = False, - skip_gitignore: bool = False, - force_exclude: bool = False, - overwrite_config: Config | None = None, - ): - """ - Initialize ConfigManager. - - Args: - sources: List of sources with Robot Framework files. - config: Path to configuration file. - root: Root of the project. Can be supplied if it's known beforehand (for example by IDE plugin) - Otherwise it will be automatically found. - ignore_git_dir: Flag for project root discovery to decide if directories with `.git` should be ignored. - ignore_file_config: If set to True, Robocop will not load found configuration files - skip_gitignore: Do not load .gitignore files when looking for the files to parse - force_exclude: Enforce exclusions, even for paths passed directly in the command-line - overwrite_config: Overwrite existing configuration file with the Config class - - """ - self.cached_configs: dict[Path, Config] = {} - self.overwrite_config = overwrite_config - self.ignore_git_dir = ignore_git_dir - self.ignore_file_config = ignore_file_config - self.force_exclude = force_exclude - self.skip_gitignore = skip_gitignore - self.gitignore_resolver = GitIgnoreResolver() - self.overridden_config = ( - config is not None - ) # TODO: what if both cli and --config? should take --config then apply cli - self.root = root or Path.cwd() - self.sources = sources - self.default_config: Config = self.get_default_config(config) - self._paths: dict[Path, Config] | None = None - self._cache: RobocopCache | None = None - - @property - def cache(self) -> RobocopCache: - """Get the file cache, initializing it lazily if needed.""" - if self._cache is None: - cache_config = self.default_config.cache - self._cache = RobocopCache( - cache_dir=cache_config.cache_dir if cache_config else None, - enabled=cache_config.enabled if cache_config else True, - verbose=self.default_config.verbose or False, - ) - return self._cache - - @property - def paths(self) -> Generator[tuple[Path, Config], None, None]: - # TODO: what if we provide the same path twice - tests - if self._paths is None: - self._paths = {} - sources = self.sources if self.sources else self.default_config.sources - ignore_file_filters = not self.force_exclude and bool(sources) - self.resolve_paths(sources, ignore_file_filters=ignore_file_filters) - yield from self._paths.items() - - def get_default_config(self, config_path: Path | None) -> Config: - """Get default config either from --config option or from the cli.""" - if config_path: - configuration = files.read_toml_config(config_path) - config = Config.from_toml(configuration, config_path.resolve()) - elif not self.ignore_file_config: - sources = [Path(path).resolve() for path in self.sources] if self.sources else [Path.cwd()] - directories = files.get_common_parent_dirs(sources) - config = self.find_config_in_dirs(directories, default=None) - if not config: - config = Config() - self.cached_configs.update(dict.fromkeys(directories, config)) - else: - config = Config() - config.overwrite_from_config(self.overwrite_config) - return config - - def is_git_project_root(self, path: Path) -> bool: - """Check if current directory contains .git directory and might be a project root.""" - if self.ignore_git_dir: - return False - return (path / ".git").is_dir() - - def find_closest_config(self, source: Path, default: Config | None) -> Config: - """Look in the directory and its parents for the closest valid configuration file.""" - return self.find_config_in_dirs(source.parents, default) - - def find_config_in_dirs(self, directories: list[Path], default: Config | None) -> Config: - seen = [] # if we find config, mark all visited directories with resolved config - for check_dir in directories: - if check_dir in self.cached_configs: - return self.cached_configs[check_dir] - seen.append(check_dir) - for config_filename in CONFIG_NAMES: - if (config_path := (check_dir / config_filename)).is_file(): - configuration = files.read_toml_config(config_path) - if configuration: - config = Config.from_toml(configuration, config_path) - config.overwrite_from_config(self.overwrite_config) # TODO those two lines together - self.cached_configs.update(dict.fromkeys(seen, config)) - if config.verbose: - print(f"Loaded {config_path} configuration file.") - return config - if self.is_git_project_root(check_dir): - break - - if default: - self.cached_configs.update(dict.fromkeys(seen, default)) - return default - - def get_config_for_source_file(self, source_file: Path) -> Config: - """ - Find the closest config to the source file or directory. - - If it was loaded before it will be returned from the cache. Otherwise, we will load it and save it to cache - first. - - Args: - source_file: Path to Robot Framework source file or directory. - - """ - if self.overridden_config or self.ignore_file_config: - return self.default_config - return self.find_closest_config(source_file, self.default_config) - - def resolve_paths( - self, - sources: list[str | Path], - ignore_file_filters: bool = False, - ) -> None: - """ - Find all files to parse and their corresponding configs. - - Initially sources can be ["."] (if not path provided, assume current working directory). - It can be also any list of paths, for example ["tests/", "file.robot"]. - - Args: - sources: list of sources from CLI or configuration file. - gitignores: list of gitignore pathspec and their locations for path resolution. - ignore_file_filters: force robocop to parse file even if it's excluded in the configuration - - """ - source_gitignore = None - config = None - for source in sources: - source_not_resolved = Path(source) - source = source_not_resolved.resolve() - if source in self._paths: - continue - if not source.exists(): - if source_not_resolved.is_symlink(): # i.e. dangling symlink - continue - raise exceptions.FatalError(f"File '{source}' does not exist") - if config is None: # first file in the directory - config = self.get_config_for_source_file(source) - if not ignore_file_filters: - if config.file_filters.path_excluded(source_not_resolved): - continue - if source.is_file() and not config.file_filters.path_included(source_not_resolved): - continue - if not self.skip_gitignore: - source_gitignore = self.gitignore_resolver.resolve_path_ignores(source_not_resolved) - if self.gitignore_resolver.path_excluded(source_not_resolved, source_gitignore): - continue - if source.is_dir(): - self.resolve_paths(source.iterdir()) - elif source.is_file(): - self._paths[source] = config diff --git a/src/robocop/config_manager.py b/src/robocop/config_manager.py new file mode 100644 index 000000000..bf6802e98 --- /dev/null +++ b/src/robocop/config_manager.py @@ -0,0 +1,261 @@ +from __future__ import annotations + +import os +from pathlib import Path +from typing import TYPE_CHECKING + +import pathspec + +from robocop import exceptions, files +from robocop.cache import RobocopCache +from robocop.config import Config +from robocop.source_file import SourceFile + +if TYPE_CHECKING: + from collections.abc import Generator + +CONFIG_NAMES = frozenset(("robocop.toml", "pyproject.toml", "robot.toml")) + + +class GitIgnoreResolver: + def __init__(self): + self.cached_ignores: dict[Path, list[pathspec.PathSpec] | None] = {} + self.ignore_dirs: set[Path] = set() + + def path_excluded(self, path: Path, gitignores: list[tuple[Path, pathspec.PathSpec]]) -> bool: + """Find path gitignores and check if file is excluded.""" + if not gitignores: + return False + for gitignore_path, gitignore in gitignores: + relative_path = files.get_relative_path(path, gitignore_path) + path = str(relative_path) + # fixes a bug in pathspec where directory needs to end with / to be ignored by pattern + if relative_path.is_dir() and path != ".": + path = f"{path}{os.sep}" + if gitignore.match_file(path): + return True + return False + + def read_gitignore(self, path: Path) -> pathspec.PathSpec: + """Return a PathSpec loaded from the file.""" + with path.open(encoding="utf-8") as gf: + lines = gf.readlines() + return pathspec.PathSpec.from_lines(pathspec.patterns.GitWildMatchPattern, lines) + + def resolve_path_ignores(self, path: Path) -> list[tuple[Path, pathspec.PathSpec]]: + """ + Visit all parent directories and find all gitignores. + + Gitignores are cached for multiple sources. + + Args: + path: path to file/directory + + Returns: + PathSpec from merged gitignores. + + """ + # TODO: respect nogitignore flag + if path.is_file(): + path = path.parent + gitignores = [] + search_paths = (parent for parent in [path, *path.parents]) + for parent_path in search_paths: + if parent_path in self.ignore_dirs: # dir that does not have .gitignore (marked as such) + gitignores.extend([self.cached_ignores[path] for path in search_paths if path in self.cached_ignores]) + break + if parent_path in self.cached_ignores: + gitignores.append(self.cached_ignores[parent_path]) + # if any parent is cached, we can retrieve any parent with gitignore from cache and return early + gitignores.extend([self.cached_ignores[path] for path in search_paths if path in self.cached_ignores]) + break + if (gitignore_path := parent_path / ".gitignore").is_file(): + gitignore = self.read_gitignore(gitignore_path) + self.cached_ignores[parent_path] = (parent_path, gitignore) + gitignores.append((parent_path, gitignore)) + else: + self.ignore_dirs.add(parent_path) + if (parent_path / ".git").is_dir(): + break + return gitignores + + +class ConfigManager: + """ + Finds and loads configuration files for each file. + + Config provided from cli takes priority. ``--config`` option overrides any found configuration file. + """ + + def __init__( + self, + sources: list[str] | None = None, + config: Path | None = None, + root: str | None = None, + ignore_git_dir: bool = False, + ignore_file_config: bool = False, + skip_gitignore: bool = False, + force_exclude: bool = False, + overwrite_config: Config | None = None, + ): + """ + Initialize ConfigManager. + + Args: + sources: List of sources with Robot Framework files. + config: Path to configuration file. + root: Root of the project. Can be supplied if it's known beforehand (for example by IDE plugin) + Otherwise it will be automatically found. + ignore_git_dir: Flag for project root discovery to decide if directories with `.git` should be ignored. + ignore_file_config: If set to True, Robocop will not load found configuration files + skip_gitignore: Do not load .gitignore files when looking for the files to parse + force_exclude: Enforce exclusions, even for paths passed directly in the command-line + overwrite_config: Overwrite existing configuration file with the Config class + + """ + self.cached_configs: dict[Path, Config] = {} + self.overwrite_config = overwrite_config + self.ignore_git_dir = ignore_git_dir + self.ignore_file_config = ignore_file_config + self.force_exclude = force_exclude + self.skip_gitignore = skip_gitignore + self.gitignore_resolver = GitIgnoreResolver() + self.overridden_config = ( + config is not None + ) # TODO: what if both cli and --config? should take --config then apply cli + self.root = root or Path.cwd() + self.sources = sources + self.default_config: Config = self.get_default_config(config) + self._paths: dict[Path, SourceFile] | None = None + self._cache: RobocopCache | None = None + + @property + def cache(self) -> RobocopCache: + """Get the file cache, initializing it lazily if needed.""" + if self._cache is None: + cache_config = self.default_config.cache + self._cache = RobocopCache( + cache_dir=cache_config.cache_dir if cache_config else None, + enabled=cache_config.enabled if cache_config else True, + verbose=self.default_config.verbose or False, + ) + return self._cache + + @property + def paths(self) -> Generator[SourceFile, None, None]: + # TODO: what if we provide the same path twice - tests + if self._paths is None: + self._paths = {} + sources = self.sources if self.sources else self.default_config.sources + ignore_file_filters = not self.force_exclude and bool(sources) + self.resolve_paths(sources, ignore_file_filters=ignore_file_filters) + yield from self._paths.values() + + def get_default_config(self, config_path: Path | None) -> Config: + """Get default config either from --config option or from the cli.""" + if config_path: + configuration = files.read_toml_config(config_path) + config = Config.from_toml(configuration, config_path.resolve()) + elif not self.ignore_file_config: + sources = [Path(path).resolve() for path in self.sources] if self.sources else [Path.cwd()] + directories = files.get_common_parent_dirs(sources) + config = self.find_config_in_dirs(directories, default=None) + if not config: + config = Config() + self.cached_configs.update(dict.fromkeys(directories, config)) + else: + config = Config() + config.overwrite_from_config(self.overwrite_config) + return config + + def is_git_project_root(self, path: Path) -> bool: + """Check if current directory contains .git directory and might be a project root.""" + if self.ignore_git_dir: + return False + return (path / ".git").is_dir() + + def find_closest_config(self, source: Path, default: Config | None) -> Config: + """Look in the directory and its parents for the closest valid configuration file.""" + return self.find_config_in_dirs(source.parents, default) + + def find_config_in_dirs(self, directories: list[Path], default: Config | None) -> Config: + seen = [] # if we find config, mark all visited directories with resolved config + for check_dir in directories: + if check_dir in self.cached_configs: + return self.cached_configs[check_dir] + seen.append(check_dir) + for config_filename in CONFIG_NAMES: + if (config_path := (check_dir / config_filename)).is_file(): + configuration = files.read_toml_config(config_path) + if configuration: + config = Config.from_toml(configuration, config_path) + config.overwrite_from_config(self.overwrite_config) # TODO those two lines together + self.cached_configs.update(dict.fromkeys(seen, config)) + if config.verbose: + print(f"Loaded {config_path} configuration file.") + return config + if self.is_git_project_root(check_dir): + break + + if default: + self.cached_configs.update(dict.fromkeys(seen, default)) + return default + + def get_config_for_source_file(self, source_file: Path) -> Config: + """ + Find the closest config to the source file or directory. + + If it was loaded before it will be returned from the cache. Otherwise, we will load it and save it to cache + first. + + Args: + source_file: Path to Robot Framework source file or directory. + + """ + if self.overridden_config or self.ignore_file_config: + return self.default_config + return self.find_closest_config(source_file, self.default_config) + + def resolve_paths( + self, + sources: list[str | Path], + ignore_file_filters: bool = False, + ) -> None: + """ + Find all files to parse and their corresponding configs. + + Initially sources can be ["."] (if not path provided, assume current working directory). + It can be also any list of paths, for example ["tests/", "file.robot"]. + + Args: + sources: list of sources from CLI or configuration file. + gitignores: list of gitignore pathspec and their locations for path resolution. + ignore_file_filters: force robocop to parse file even if it's excluded in the configuration + + """ + source_gitignore = None + config = None + for source in sources: + source_not_resolved = Path(source) + source = source_not_resolved.resolve() + if source in self._paths: + continue + if not source.exists(): + if source_not_resolved.is_symlink(): # i.e. dangling symlink + continue + raise exceptions.FatalError(f"File '{source}' does not exist") + if config is None: # first file in the directory + config = self.get_config_for_source_file(source) + if not ignore_file_filters: + if config.file_filters.path_excluded(source_not_resolved): + continue + if source.is_file() and not config.file_filters.path_included(source_not_resolved): + continue + if not self.skip_gitignore: + source_gitignore = self.gitignore_resolver.resolve_path_ignores(source_not_resolved) + if self.gitignore_resolver.path_excluded(source_not_resolved, source_gitignore): + continue + if source.is_dir(): + self.resolve_paths(source.iterdir()) + elif source.is_file(): + self._paths[source] = SourceFile(path=source, config=config) diff --git a/src/robocop/exceptions.py b/src/robocop/exceptions.py index b5fc5b0d3..d2e1ef1fb 100644 --- a/src/robocop/exceptions.py +++ b/src/robocop/exceptions.py @@ -4,7 +4,6 @@ import typer from rich.console import Console -from robot.errors import DataError if TYPE_CHECKING: from robocop.linter.rules import Rule @@ -101,22 +100,3 @@ def __init__(self): class CircularExtendsReferenceError(FatalError): def __init__(self, config_path: str): super().__init__(f"Circular reference found in 'extends' parameter in the configuration file: {config_path}") - - -def handle_robot_errors(func): - """ - Handle bugs in Robot Framework. - - If the user uses an older version of Robot Framework, it may fail while parsing the - source code due to a bug that is already fixed in the more recent version. - """ - - def wrap_errors(*args, **kwargs): # noqa: ANN202 - try: - return func(*args, **kwargs) - except DataError: - raise - except Exception as err: - raise RobotFrameworkParsingError from err - - return wrap_errors diff --git a/src/robocop/formatter/formatters/NormalizeTags.py b/src/robocop/formatter/formatters/NormalizeTags.py index 47d9f18a1..baf1078d8 100644 --- a/src/robocop/formatter/formatters/NormalizeTags.py +++ b/src/robocop/formatter/formatters/NormalizeTags.py @@ -3,7 +3,7 @@ from robocop.exceptions import InvalidParameterValueError from robocop.formatter.disablers import skip_section_if_disabled from robocop.formatter.formatters import Formatter -from robocop.formatter.utils import variable_matcher +from robocop.parsing.variables import VariableMatches class NormalizeTags(Formatter): @@ -76,7 +76,7 @@ def format_with_case_function(self, string: str) -> str: return self.CASE_FUNCTIONS[self.case_function](string) tag = "" var_found = False - for match in variable_matcher.VariableMatches(string, ignore_errors=True): + for match in VariableMatches(string, ignore_errors=True): var_found = True tag += self.CASE_FUNCTIONS[self.case_function](match.before) tag += match.match diff --git a/src/robocop/formatter/formatters/RenameKeywords.py b/src/robocop/formatter/formatters/RenameKeywords.py index 27b1fed41..749cc128e 100644 --- a/src/robocop/formatter/formatters/RenameKeywords.py +++ b/src/robocop/formatter/formatters/RenameKeywords.py @@ -7,8 +7,9 @@ from robocop.exceptions import InvalidParameterValueError from robocop.formatter.disablers import skip_if_disabled, skip_section_if_disabled from robocop.formatter.formatters import Formatter -from robocop.formatter.utils import misc, variable_matcher +from robocop.formatter.utils import misc from robocop.parsing.run_keywords import RUN_KEYWORDS +from robocop.parsing.variables import VariableMatches class RenameKeywords(Formatter): @@ -111,7 +112,7 @@ def normalize_name(self, value, is_keyword_call): var_found = False parts = [] after = "" - for match in variable_matcher.VariableMatches(value, ignore_errors=True): + for match in VariableMatches(value, ignore_errors=True): var_found = True # rename strips whitespace, so we need to preserve it if needed if not match.before.strip() and parts: @@ -156,7 +157,7 @@ def rename_with_pattern(self, value: str, is_keyword_call: bool): if is_keyword_call and "." in value: # rename only non lib part found_lib = -1 - for match in variable_matcher.VariableMatches(value): + for match in VariableMatches(value): found_lib = match.before.find(".") break if found_lib != -1: diff --git a/src/robocop/formatter/formatters/RenameVariables.py b/src/robocop/formatter/formatters/RenameVariables.py index 200f549df..c8581524a 100644 --- a/src/robocop/formatter/formatters/RenameVariables.py +++ b/src/robocop/formatter/formatters/RenameVariables.py @@ -12,8 +12,9 @@ from robocop.exceptions import InvalidParameterValueError from robocop.formatter.disablers import skip_if_disabled, skip_section_if_disabled from robocop.formatter.formatters import Formatter -from robocop.formatter.utils import misc, variable_matcher +from robocop.formatter.utils import misc from robocop.linter.utils.misc import remove_variable_type_conversion +from robocop.parsing.variables import VariableMatches if TYPE_CHECKING: from robocop.formatter.skip import Skip @@ -475,7 +476,7 @@ def _is_var_scope_local(node): def rename_value(self, value: str, variable_case: VariableCase, is_var: bool = False): try: - variables = list(variable_matcher.VariableMatches(value)) + variables = list(VariableMatches(value)) except VariableError: # for example ${variable which wasn't closed properly variables = [] if not variables: diff --git a/src/robocop/formatter/formatters/ReplaceReturns.py b/src/robocop/formatter/formatters/ReplaceReturns.py index 1fa101bf8..5362f8ceb 100644 --- a/src/robocop/formatter/formatters/ReplaceReturns.py +++ b/src/robocop/formatter/formatters/ReplaceReturns.py @@ -8,6 +8,7 @@ from robocop.formatter.disablers import skip_if_disabled, skip_section_if_disabled from robocop.formatter.formatters import Formatter from robocop.formatter.utils import misc +from robocop.version_handling import ROBOT_VERSION class ReplaceReturns(Formatter): @@ -87,7 +88,7 @@ def visit_ReturnSetting(self, node): # noqa: N802 @skip_if_disabled def visit_Return(self, node): # noqa: N802 - if misc.ROBOT_VERSION.major < 7: # In RF 7, RETURN was class was renamed to Return + if ROBOT_VERSION.major < 7: # In RF 7, RETURN was class was renamed to Return self.return_statement = node return None return node diff --git a/src/robocop/formatter/formatters/SplitTooLongLine.py b/src/robocop/formatter/formatters/SplitTooLongLine.py index 74e5b3711..5e7ad88e7 100644 --- a/src/robocop/formatter/formatters/SplitTooLongLine.py +++ b/src/robocop/formatter/formatters/SplitTooLongLine.py @@ -10,9 +10,9 @@ from robocop.formatter.disablers import skip_if_disabled, skip_section_if_disabled from robocop.formatter.formatters import Formatter -from robocop.formatter.utils.misc import ROBOT_VERSION from robocop.linter.utils.disablers import DISABLER_PATTERN from robocop.parsing.run_keywords import RUN_KEYWORDS +from robocop.version_handling import INLINE_IF_SUPPORTED if TYPE_CHECKING: from robocop.formatter.skip import Skip @@ -126,7 +126,7 @@ def visit_If(self, node): # noqa: N802 @staticmethod def is_inline(node): - return ROBOT_VERSION.major > 4 and isinstance(node.header, InlineIfHeader) + return INLINE_IF_SUPPORTED and isinstance(node.header, InlineIfHeader) def should_format_node(self, node): if not self.any_line_too_long(node): diff --git a/src/robocop/formatter/formatters/__init__.py b/src/robocop/formatter/formatters/__init__.py index 78ab7c78e..75b81e321 100644 --- a/src/robocop/formatter/formatters/__init__.py +++ b/src/robocop/formatter/formatters/__init__.py @@ -29,6 +29,7 @@ from robocop.exceptions import ImportFormatterError, InvalidParameterError from robocop.formatter.skip import SKIP_OPTIONS, Skip, SkipConfig from robocop.formatter.utils import misc +from robocop.version_handling import ROBOT_VERSION if TYPE_CHECKING: from collections.abc import Generator @@ -315,10 +316,10 @@ def can_run_in_robot_version(formatter, overwritten, target_version): return True if overwritten: # --select FormatterDisabledInVersion or --configure FormatterDisabledInVersion.enabled=True - if target_version == misc.ROBOT_VERSION.major: + if target_version == ROBOT_VERSION.major: click.echo( f"{formatter.__class__.__name__} formatter requires Robot Framework {formatter.MIN_VERSION}.* " - f"version but you have {misc.ROBOT_VERSION} installed. " + f"version but you have {ROBOT_VERSION} installed. " f"Upgrade installed Robot Framework if you want to use this formatter.", err=True, ) diff --git a/src/robocop/formatter/runner.py b/src/robocop/formatter/runner.py index 0182d79fe..baf1f53c4 100644 --- a/src/robocop/formatter/runner.py +++ b/src/robocop/formatter/runner.py @@ -1,6 +1,5 @@ from __future__ import annotations -import hashlib import os import sys from difflib import unified_diff @@ -21,7 +20,8 @@ from robot.parsing import File - from robocop.config import Config, ConfigManager + from robocop.config import Config + from robocop.config_manager import ConfigManager console = console.Console() @@ -32,31 +32,6 @@ def __init__(self, config_manager: ConfigManager): self.config_manager = config_manager self.config: Config = self.config_manager.default_config - def get_model(self, source: Path) -> File: - if misc.rf_supports_lang(): - return get_model(source, lang=self.config.formatter.languages) - return get_model(source) - - @staticmethod - def _compute_cache_key(config: Config) -> str: - """ - Compute cache key combining formatter config hash with language. - - Uses SHA256 for stable hashing across Python processes, unlike the built-in - hash() which can vary due to hash randomization (PEP 456). - - Returns: - A string representing the cache key as a hexadecimal digest. - - """ - hasher = hashlib.sha256() - # Hash the formatter config - hasher.update(str(hash(config.formatter)).encode("utf-8")) - # Hash the language configuration (affects parsing) - language_str = ":".join(sorted(config.language or [])) - hasher.update(language_str.encode("utf-8")) - return hasher.hexdigest() - def run(self) -> int: changed_files = 0 skipped_files = 0 @@ -65,7 +40,7 @@ def run(self) -> int: previous_changed_files = 0 # TODO: hold in one container stdin = False - for source, config in self.config_manager.paths: + for source_file in self.config_manager.paths: try: # stdin = False # if str(source) == "-": @@ -74,35 +49,37 @@ def run(self) -> int: # click.echo("Loading file from stdin") # source = self.load_from_stdin() if self.config.verbose: - print(f"Formatting {source} file") - self.config = config + print(f"Formatting {source_file.path} file") + self.config = source_file.config all_files += 1 - # Check cache - if file hasn't changed and didn't need formatting before, skip it - config_hash = self._compute_cache_key(config) - cached_entry = self.config_manager.cache.get_formatter_entry(source, config_hash) - - if cached_entry is not None and not cached_entry.needs_formatting: - # File hasn't changed and didn't need formatting - skip it - cached_files += 1 - continue + if source_file.config.cache.enabled: + # Check cache - if file hasn't changed and didn't need formatting before, skip it + cached_entry = self.config_manager.cache.get_formatter_entry( + source_file.path, source_file.config.hash() + ) + if cached_entry is not None and not cached_entry.needs_formatting: + # File hasn't changed and didn't need formatting - skip it + cached_files += 1 + continue previous_changed_files = changed_files - model = self.get_model(source) - diff, old_model, new_model, model = self.format_until_stable(model) + diff, old_model, new_model, model = self.format_until_stable(source_file.model) # if stdin: # self.print_to_stdout(new_model) if diff: - model_path = model.source or source + model_path = model.source or source_file.path self.save_model(model_path, model) - self.log_formatted_source(source, stdin) + self.log_formatted_source(source_file.path, stdin) self.output_diff(model_path, old_model, new_model) changed_files += 1 # Cache result - after formatting (or if no changes needed), file is now clean - self.config_manager.cache.set_formatter_entry(source, config_hash, needs_formatting=False) + self.config_manager.cache.set_formatter_entry( + source_file.path, source_file.config.hash(), needs_formatting=False + ) except DataError as err: - if not config.silent: - print(f"Failed to decode {source} with an error: {err}\nSkipping file") # TODO stderr + if not source_file.config.silent: + print(f"Failed to decode {source_file.path} with an error: {err}\nSkipping file") # TODO stderr changed_files = previous_changed_files skipped_files += 1 diff --git a/src/robocop/formatter/utils/misc.py b/src/robocop/formatter/utils/misc.py index 195100422..2bf162038 100644 --- a/src/robocop/formatter/utils/misc.py +++ b/src/robocop/formatter/utils/misc.py @@ -3,7 +3,6 @@ import ast import difflib import re -from functools import total_ordering from re import Pattern import click @@ -18,51 +17,11 @@ from robot.api.parsing import Comment, End, If, IfHeader, ModelVisitor, Token from robot.parsing.model import Statement from robot.utils.robotio import file_writer -from robot.version import VERSION as RF_VERSION if TYPE_CHECKING: from collections.abc import Iterable -@total_ordering -class Version: - def __init__(self, major: int, minor: int, fix: int): - self.major = major - self.minor = minor - self.fix = fix - - @classmethod - def parse(cls, raw_version) -> Version: - version = re.search(r"(?P[0-9]+)\.(?P[0-9]+)\.?(?P[0-9]+)*", raw_version) - major = int(version.group("major")) - minor = int(version.group("minor")) if version.group("minor") is not None else 0 - fix = int(version.group("fix")) if version.group("fix") is not None else 0 - return cls(major, minor, fix) - - def __eq__(self, other): - return self.major == other.major and self.minor == other.minor and self.fix == other.fix - - def __lt__(self, other): - if self.major != other.major: - return self.major < other.major - if self.minor != other.minor: - return self.minor < other.minor - return self.fix < other.fix - - def __hash__(self): - return hash((self.major, self.minor, self.fix)) - - def __str__(self): - return f"{self.major}.{self.minor}.{self.fix}" - - -ROBOT_VERSION = Version.parse(RF_VERSION) - - -def rf_supports_lang(): - return ROBOT_VERSION.major >= 6 - - class StatementLinesCollector(ModelVisitor): """Used to get a writeable presentation of a Robot Framework model.""" diff --git a/src/robocop/formatter/utils/variable_matcher.py b/src/robocop/formatter/utils/variable_matcher.py deleted file mode 100644 index 528476452..000000000 --- a/src/robocop/formatter/utils/variable_matcher.py +++ /dev/null @@ -1,33 +0,0 @@ -# TODO copy from linter - take to common -from robot.variables.search import VariableMatch, search_variable - -try: - from robot.variables import VariableMatches -except ImportError: - from collections.abc import Iterator, Sequence - - class VariableMatches: - def __init__(self, string: str, identifiers: Sequence[str] = "$@&%", ignore_errors: bool = False): - self.string = string - self.identifiers = identifiers - self.ignore_errors = ignore_errors - - def __iter__(self) -> Iterator[VariableMatch]: - remaining = self.string - while True: - match = search_variable(remaining, self.identifiers, self.ignore_errors) - if not match: - break - remaining = match.after - yield match - - def __len__(self) -> int: - return sum(1 for _ in self) - - def __bool__(self) -> bool: - try: - next(iter(self)) - except StopIteration: - return False - else: - return True diff --git a/src/robocop/linter/reports/sarif.py b/src/robocop/linter/reports/sarif.py index e78eeb803..2d3146852 100644 --- a/src/robocop/linter/reports/sarif.py +++ b/src/robocop/linter/reports/sarif.py @@ -2,7 +2,8 @@ import robocop.linter.reports from robocop import __version__ -from robocop.config import Config, ConfigManager +from robocop.config import Config +from robocop.config_manager import ConfigManager from robocop.files import get_relative_path from robocop.linter.diagnostics import Diagnostics from robocop.linter.rules import Rule diff --git a/src/robocop/linter/reports/sonarqube.py b/src/robocop/linter/reports/sonarqube.py index dd6576786..e2d8662c4 100644 --- a/src/robocop/linter/reports/sonarqube.py +++ b/src/robocop/linter/reports/sonarqube.py @@ -1,7 +1,8 @@ from pathlib import Path import robocop.linter.reports -from robocop.config import Config, ConfigManager +from robocop.config import Config +from robocop.config_manager import ConfigManager from robocop.exceptions import ConfigurationError from robocop.files import get_relative_path from robocop.linter import sonar_qube diff --git a/src/robocop/linter/rules/__init__.py b/src/robocop/linter/rules/__init__.py index 66e662afb..8027762ae 100644 --- a/src/robocop/linter/rules/__init__.py +++ b/src/robocop/linter/rules/__init__.py @@ -41,7 +41,7 @@ from robocop import __version__, exceptions from robocop.linter.diagnostics import Diagnostic -from robocop.linter.utils.version_matching import Version, VersionSpecifier +from robocop.version_handling import Version, VersionSpecifier try: import annotationlib @@ -59,7 +59,8 @@ from robot.parsing import File - from robocop.config import ConfigManager, LinterConfig + from robocop.config import LinterConfig + from robocop.config_manager import ConfigManager from robocop.linter import sonar_qube diff --git a/src/robocop/linter/rules/comments.py b/src/robocop/linter/rules/comments.py index fb2cd35c2..000e237ab 100644 --- a/src/robocop/linter/rules/comments.py +++ b/src/robocop/linter/rules/comments.py @@ -18,7 +18,7 @@ RuleSeverity, VisitorChecker, ) -from robocop.linter.utils.misc import ROBOT_VERSION +from robocop.version_handling import ROBOT_VERSION if TYPE_CHECKING: from robot.parsing.model import Keyword, Statement, TestCase @@ -179,6 +179,7 @@ class InvalidCommentRule(Rule): issue_type=sonar_qube.SonarQubeIssueType.BUG, ) deprecated_names = ("0703",) + # TODO: deprecate (<4) class IgnoredDataRule(Rule): diff --git a/src/robocop/linter/rules/errors.py b/src/robocop/linter/rules/errors.py index 0a11b3afb..fca363a17 100644 --- a/src/robocop/linter/rules/errors.py +++ b/src/robocop/linter/rules/errors.py @@ -11,7 +11,8 @@ from robocop.linter import sonar_qube from robocop.linter.rules import Rule, RuleSeverity, VisitorChecker, arguments, whitespace -from robocop.linter.utils.misc import ROBOT_VERSION, find_robot_vars +from robocop.linter.utils.misc import find_robot_vars +from robocop.version_handling import ROBOT_VERSION class ParsingErrorRule(Rule): # TODO docs @@ -347,11 +348,8 @@ def visit_InvalidSection(self, node) -> None: # noqa: N802 def parse_errors(self, node) -> None: if node is None: return - if ROBOT_VERSION.major != 3: - for index, error in enumerate(node.errors): - self.handle_error(node, error, error_index=index) - else: - self.handle_error(node, node.error) + for index, error in enumerate(node.errors): + self.handle_error(node, error, error_index=index) def handle_error(self, node, error, error_index=0) -> None: if not error: diff --git a/src/robocop/linter/rules/lengths.py b/src/robocop/linter/rules/lengths.py index 88c210f92..9949df34c 100644 --- a/src/robocop/linter/rules/lengths.py +++ b/src/robocop/linter/rules/lengths.py @@ -44,11 +44,11 @@ get_section_name, normalize_robot_name, pattern_type, - rf_supports_type, split_argument_default_value, str2bool, strip_equals_from_assignment, ) +from robocop.version_handling import TYPE_SUPPORTED if TYPE_CHECKING: from robot.parsing.model import VariableSection @@ -938,7 +938,7 @@ def add_variable_to_scope(self, node, var_name=None, has_pattern=False): end_col_offset += 1 # leading backslash # Strip all item and possible type conversions : ${list}[0] , ${var: int} try: - search = search_variable(var_name, parse_type=True) if rf_supports_type() else search_variable(var_name) # pylint: disable=unexpected-keyword-arg + search = search_variable(var_name, parse_type=True) if TYPE_SUPPORTED else search_variable(var_name) # pylint: disable=unexpected-keyword-arg except VariableError: # Incomplete data, e.g. `${arg` return # Handle var name without brackets passed to Set Local/Global/Suite/Test Variable : $var diff --git a/src/robocop/linter/rules/misc.py b/src/robocop/linter/rules/misc.py index 882c58840..b7b5941ab 100644 --- a/src/robocop/linter/rules/misc.py +++ b/src/robocop/linter/rules/misc.py @@ -41,7 +41,8 @@ variables, ) from robocop.linter.utils import misc as utils -from robocop.linter.utils.variable_matcher import VariableMatches +from robocop.parsing.variables import VariableMatches +from robocop.version_handling import INLINE_IF_SUPPORTED, ROBOT_VERSION if TYPE_CHECKING: from robocop.linter.utils.disablers import DisablersFinder @@ -1071,7 +1072,7 @@ def tokens_length(tokens): return sum(len(token.value) for token in tokens) def check_whether_if_should_be_inline(self, node) -> None: - if utils.ROBOT_VERSION.major < 5: + if not INLINE_IF_SUPPORTED: return if self.is_inline_if(node): if node.lineno != node.end_lineno: @@ -1452,7 +1453,7 @@ def visit_For(self, node): # noqa: N802 @staticmethod def try_assign(try_node) -> str: - if utils.ROBOT_VERSION.major < 7: + if ROBOT_VERSION.major < 7: return try_node.variable return try_node.assign diff --git a/src/robocop/linter/rules/naming.py b/src/robocop/linter/rules/naming.py index 2df0976b5..b2859937b 100644 --- a/src/robocop/linter/rules/naming.py +++ b/src/robocop/linter/rules/naming.py @@ -17,8 +17,9 @@ from robocop.linter import sonar_qube from robocop.linter.rules import Rule, RuleParam, RuleSeverity, VisitorChecker, deprecated, variables from robocop.linter.utils import misc as utils -from robocop.linter.utils.variable_matcher import VariableMatches from robocop.parsing.run_keywords import iterate_keyword_names +from robocop.parsing.variables import VariableMatches +from robocop.version_handling import ROBOT_VERSION if TYPE_CHECKING: from collections.abc import Iterable @@ -851,7 +852,7 @@ def check_if_keyword_is_reserved(self, keyword_name, node) -> bool: if lower_name in self.else_statements and self.inside_if_block: return False # handled by else-not-upper-case min_ver = self.reserved_words[lower_name] - if utils.ROBOT_VERSION.major < min_ver: + if ROBOT_VERSION.major < min_ver: return False error_msg = uppercase_error_msg(lower_name) self.report( @@ -875,9 +876,9 @@ class SettingsNamingChecker(VisitorChecker): invalid_section: InvalidSectionRule mixed_task_test_settings: MixedTaskTestSettingsRule - ALIAS_TOKENS = [Token.WITH_NAME] if utils.ROBOT_VERSION.major < 5 else ["WITH NAME", "AS"] + ALIAS_TOKENS = [Token.WITH_NAME] if ROBOT_VERSION.major < 5 else ["WITH NAME", "AS"] # Separating alias values since RF 3 uses WITH_NAME instead of WITH NAME - ALIAS_TOKENS_VALUES = ["WITH NAME"] if utils.ROBOT_VERSION.major < 5 else ["WITH NAME", "AS"] + ALIAS_TOKENS_VALUES = ["WITH NAME"] if ROBOT_VERSION.major < 5 else ["WITH NAME", "AS"] def __init__(self): self.section_name_pattern = re.compile(r"\*\*\*\s.+\s\*\*\*") @@ -914,8 +915,8 @@ def visit_File(self, node) -> None: # noqa: N802 self.task_section = None for section in node.sections: if isinstance(section, TestCaseSection): - if (utils.ROBOT_VERSION.major < 6 and "task" in section.header.name.lower()) or ( - utils.ROBOT_VERSION.major >= 6 and section.header.type == Token.TASK_HEADER + if (ROBOT_VERSION.major < 6 and "task" in section.header.name.lower()) or ( + ROBOT_VERSION.major >= 6 and section.header.type == Token.TASK_HEADER ): self.task_section = True else: @@ -937,7 +938,7 @@ def visit_Setup(self, node) -> None: # noqa: N802 def visit_LibraryImport(self, node) -> None: # noqa: N802 self.check_setting_name(node.data_tokens[0].value, node) - if utils.ROBOT_VERSION.major < 6: + if ROBOT_VERSION.major < 6: arg_nodes = node.get_tokens(Token.ARGUMENT) # ignore cases where 'AS' is used to provide library alias for RF < 5 if arg_nodes and any(arg.value == "AS" for arg in arg_nodes): @@ -1245,7 +1246,7 @@ def visit_While(self, node): # noqa: N802 @staticmethod def for_assign_vars(for_node) -> Iterable[str]: - if utils.ROBOT_VERSION.major < 7: + if ROBOT_VERSION.major < 7: yield from for_node.variables else: yield from for_node.assign @@ -1438,8 +1439,9 @@ def visit_Template(self, node) -> None: # noqa: N802 def visit_Return(self, node) -> None: # noqa: N802 """For RETURN use visit_ReturnStatement - visit_Return will most likely visit RETURN in the future""" - if utils.ROBOT_VERSION.major not in (5, 6): + if ROBOT_VERSION.major not in (5, 6): return + # TODO: check if our code for finding our return visitor would apply here self.check_deprecated_return(node) def visit_ReturnSetting(self, node) -> None: # noqa: N802 @@ -1457,7 +1459,7 @@ def check_deprecated_return(self, node) -> None: ) def visit_ForceTags(self, node) -> None: # noqa: N802 - if utils.ROBOT_VERSION.major < 6: + if ROBOT_VERSION.major < 6: return setting_name = node.data_tokens[0].value.lower() if setting_name == "force tags": @@ -1476,7 +1478,7 @@ def check_if_keyword_is_deprecated(self, keyword_name, node) -> None: if normalized_keyword_name not in self.deprecated_keywords: return version, alternative = self.deprecated_keywords[normalized_keyword_name] - if version > utils.ROBOT_VERSION.major: + if version > ROBOT_VERSION.major: return col = utils.token_col(node, Token.NAME, Token.KEYWORD) self.report( @@ -1490,7 +1492,7 @@ def check_if_keyword_is_deprecated(self, keyword_name, node) -> None: ) def check_keyword_can_be_replaced_with_var(self, keyword_name, node) -> None: - if utils.ROBOT_VERSION.major < 7: + if ROBOT_VERSION.major < 7: return normalized = utils.normalize_robot_name(keyword_name, remove_prefix="builtin.") col = utils.token_col(node, Token.NAME, Token.KEYWORD) @@ -1512,7 +1514,7 @@ def check_keyword_can_be_replaced_with_var(self, keyword_name, node) -> None: ) def visit_LibraryImport(self, node) -> None: # noqa: N802 - if utils.ROBOT_VERSION.major < 5 or (utils.ROBOT_VERSION.major == 5 and utils.ROBOT_VERSION.minor == 0): + if ROBOT_VERSION.major < 5 or (ROBOT_VERSION.major == 5 and ROBOT_VERSION.minor == 0): return with_name_token = node.get_token(Token.WITH_NAME) if not with_name_token or with_name_token.value == "AS": diff --git a/src/robocop/linter/rules/spacing.py b/src/robocop/linter/rules/spacing.py index 1d88d6460..1629332cb 100644 --- a/src/robocop/linter/rules/spacing.py +++ b/src/robocop/linter/rules/spacing.py @@ -11,8 +11,6 @@ from robot.parsing.model.blocks import Keyword, TestCase from robot.parsing.model.statements import Comment, EmptyLine, KeywordCall -from robocop.linter.utils.misc import ROBOT_VERSION - try: from robot.api.parsing import InlineIfHeader except ImportError: @@ -22,6 +20,7 @@ from robocop.linter.rules import RawFileChecker, Rule, RuleParam, RuleSeverity, SeverityThreshold, VisitorChecker from robocop.linter.utils.misc import get_errors, get_section_name, str2bool, token_col from robocop.parsing.run_keywords import is_run_keyword +from robocop.version_handling import INLINE_IF_SUPPORTED if TYPE_CHECKING: from robot.parsing import File @@ -1206,7 +1205,7 @@ def is_inline_if(node): def visit_If(self, node) -> None: # noqa: N802 # suppress the rules if the multiline-inline-if is already reported - if ROBOT_VERSION.major >= 5 and self.is_inline_if(node): + if INLINE_IF_SUPPORTED and self.is_inline_if(node): return def is_ignorable_run_keyword(self, node) -> bool: diff --git a/src/robocop/linter/rules/tags.py b/src/robocop/linter/rules/tags.py index 6dbe158bd..c74fdeab6 100644 --- a/src/robocop/linter/rules/tags.py +++ b/src/robocop/linter/rules/tags.py @@ -9,7 +9,7 @@ from robocop.linter import sonar_qube from robocop.linter.rules import Rule, RuleSeverity, VisitorChecker -from robocop.linter.utils import variable_matcher +from robocop.parsing.variables import VariableMatches if TYPE_CHECKING: from robot.parsing import File @@ -408,7 +408,7 @@ def check_tag(self, tag_token: Token, node: type[Node]) -> None: var_found = False substrings = [] after = tag_token.value - for match in variable_matcher.VariableMatches(tag_token.value, ignore_errors=True): + for match in VariableMatches(tag_token.value, ignore_errors=True): substrings.append(match.before) var_found = var_found or bool(match.match) after = match.after diff --git a/src/robocop/linter/rules/usage.py b/src/robocop/linter/rules/usage.py index 85988d995..000beddd3 100644 --- a/src/robocop/linter/rules/usage.py +++ b/src/robocop/linter/rules/usage.py @@ -16,7 +16,7 @@ from robocop.parsing.run_keywords import iterate_keyword_names if TYPE_CHECKING: - from robocop.config import ConfigManager + from robocop.config_manager import ConfigManager from robocop.linter.diagnostics import Diagnostic diff --git a/src/robocop/linter/runner.py b/src/robocop/linter/runner.py index a48c54aec..66088abc1 100644 --- a/src/robocop/linter/runner.py +++ b/src/robocop/linter/runner.py @@ -20,7 +20,8 @@ from robot.parsing import File - from robocop.config import Config, ConfigManager + from robocop.config import Config + from robocop.config_manager import ConfigManager from robocop.linter.diagnostics import Diagnostic @@ -99,21 +100,21 @@ def run(self) -> list[Diagnostic]: self.diagnostics: list[Diagnostic] = [] files = 0 cached_files = 0 - for source, config in self.config_manager.paths: - if config.verbose: - print(f"Scanning file: {source}") - diagnostics = self.get_cached_diagnostics(config, source) + for source_file in self.config_manager.paths: + if source_file.config.verbose: + print(f"Scanning file: {source_file.path}") + diagnostics = self.get_cached_diagnostics(source_file.config, source_file.path) if diagnostics is not None: self.diagnostics.extend(diagnostics) files += 1 cached_files += 1 continue - diagnostics = self.get_model_diagnostics(config, source) + diagnostics = self.get_model_diagnostics(source_file.config, source_file.path) if diagnostics is None: continue self.diagnostics.extend(diagnostics) files += 1 - self.config_manager.cache.set_linter_entry(source, config.hash(), diagnostics) + self.config_manager.cache.set_linter_entry(source_file.path, source_file.config.hash(), diagnostics) self.config_manager.cache.save() if not files and not self.config_manager.default_config.silent: diff --git a/src/robocop/linter/utils/file_types.py b/src/robocop/linter/utils/file_types.py index e9a166c02..415d008f9 100644 --- a/src/robocop/linter/utils/file_types.py +++ b/src/robocop/linter/utils/file_types.py @@ -4,8 +4,7 @@ from typing import TYPE_CHECKING, Callable -from robocop import exceptions -from robocop.linter.utils.misc import rf_supports_lang +from robocop.version_handling import LANG_SUPPORTED if TYPE_CHECKING: from pathlib import Path @@ -13,8 +12,7 @@ from robot.parsing.model import File -@exceptions.handle_robot_errors def get_resource_with_lang(get_resource_method: Callable, source: Path, lang: str | None) -> File: - if rf_supports_lang(): + if LANG_SUPPORTED: return get_resource_method(source, lang=lang) return get_resource_method(source) diff --git a/src/robocop/linter/utils/misc.py b/src/robocop/linter/utils/misc.py index 46db20e9f..dfc4be76d 100644 --- a/src/robocop/linter/utils/misc.py +++ b/src/robocop/linter/utils/misc.py @@ -21,10 +21,9 @@ from robot.parsing.model.statements import Variable from robot.variables.search import search_variable -from robot.version import VERSION as RF_VERSION -from robocop.linter.utils.variable_matcher import VariableMatches -from robocop.linter.utils.version_matching import Version +from robocop.parsing.variables import VariableMatches +from robocop.version_handling import ROBOT_VERSION if TYPE_CHECKING: from collections.abc import Generator @@ -34,9 +33,6 @@ from robocop.linter.diagnostics import Diagnostic -ROBOT_VERSION = Version(RF_VERSION) -ROBOT_WITH_LANG = Version("6.0") -ROBOT_WITH_TYPE = Version("7.3") ROBOCOP_RULES_URL = "https://robocop.dev/{version}/rules_list/" @@ -71,14 +67,6 @@ def get_return_classes() -> ReturnClasses: RETURN_CLASSES = get_return_classes() -def rf_supports_lang() -> bool: - return ROBOT_VERSION >= ROBOT_WITH_LANG - - -def rf_supports_type() -> bool: - return ROBOT_VERSION >= ROBOT_WITH_TYPE - - def remove_variable_type_conversion(name: str) -> str: name, *_ = name.split(": ", maxsplit=1) return name diff --git a/src/robocop/mcp/tools/linting.py b/src/robocop/mcp/tools/linting.py index 66c0832a0..e763cf2e0 100644 --- a/src/robocop/mcp/tools/linting.py +++ b/src/robocop/mcp/tools/linting.py @@ -7,7 +7,8 @@ from fastmcp.exceptions import ToolError from robot.errors import DataError -from robocop.config import Config, ConfigManager, LinterConfig +from robocop.config import Config, LinterConfig +from robocop.config_manager import ConfigManager from robocop.mcp.tools.models import DiagnosticResult from robocop.mcp.tools.utils.constants import VALID_EXTENSIONS from robocop.mcp.tools.utils.helpers import ( diff --git a/src/robocop/linter/utils/variable_matcher.py b/src/robocop/parsing/variables.py similarity index 90% rename from src/robocop/linter/utils/variable_matcher.py rename to src/robocop/parsing/variables.py index a18ded906..e93adb625 100644 --- a/src/robocop/linter/utils/variable_matcher.py +++ b/src/robocop/parsing/variables.py @@ -1,32 +1,32 @@ -from robot.variables.search import VariableMatch, search_variable - -try: - from robot.variables import VariableMatches -except ImportError: - from collections.abc import Iterator, Sequence - - class VariableMatches: - def __init__(self, string: str, identifiers: Sequence[str] = "$@&%", ignore_errors: bool = False): - self.string = string - self.identifiers = identifiers - self.ignore_errors = ignore_errors - - def __iter__(self) -> Iterator[VariableMatch]: - remaining = self.string - while True: - match = search_variable(remaining, self.identifiers, self.ignore_errors) - if not match: - break - remaining = match.after - yield match - - def __len__(self) -> int: - return sum(1 for _ in self) - - def __bool__(self) -> bool: - try: - next(iter(self)) - except StopIteration: - return False - else: - return True +try: + from robot.variables import VariableMatches +except ImportError: + from collections.abc import Iterator, Sequence + + from robot.variables.search import VariableMatch, search_variable + + class VariableMatches: + def __init__(self, string: str, identifiers: Sequence[str] = "$@&%", ignore_errors: bool = False): + self.string = string + self.identifiers = identifiers + self.ignore_errors = ignore_errors + + def __iter__(self) -> Iterator[VariableMatch]: + remaining = self.string + while True: + match = search_variable(remaining, self.identifiers, self.ignore_errors) + if not match: + break + remaining = match.after + yield match + + def __len__(self) -> int: + return sum(1 for _ in self) + + def __bool__(self) -> bool: + try: + next(iter(self)) + except StopIteration: + return False + else: + return True diff --git a/src/robocop/run.py b/src/robocop/run.py index 08a89e2db..0b75ffe20 100644 --- a/src/robocop/run.py +++ b/src/robocop/run.py @@ -6,7 +6,7 @@ import typer from rich.console import Console -from robocop import __version__, config +from robocop import __version__, config, config_manager from robocop.formatter.runner import RobocopFormatter from robocop.formatter.skip import SkipConfig from robocop.linter.diagnostics import Diagnostic @@ -353,7 +353,7 @@ def check_files( silent=silent, target_version=target_version, ) - config_manager = config.ConfigManager( + manager = config_manager.ConfigManager( sources=sources, config=configuration_file, root=root, @@ -364,8 +364,8 @@ def check_files( overwrite_config=overwrite_config, ) if clear_cache: - config_manager.cache.invalidate_all() - runner = RobocopLinter(config_manager) + manager.cache.invalidate_all() + runner = RobocopLinter(manager) return runner.run() @@ -468,7 +468,7 @@ def check_project( silent=silent, target_version=target_version, ) - config_manager = config.ConfigManager( + manager = config_manager.ConfigManager( sources=sources, config=configuration_file, root=root, @@ -478,7 +478,7 @@ def check_project( force_exclude=force_exclude, overwrite_config=overwrite_config, ) - runner = RobocopLinter(config_manager) + runner = RobocopLinter(manager) return runner.run_project_checks() @@ -696,7 +696,7 @@ def format_files( silent=silent, target_version=target_version, ) - config_manager = config.ConfigManager( + manager = config_manager.ConfigManager( sources=sources, config=configuration_file, root=root, @@ -707,8 +707,8 @@ def format_files( overwrite_config=overwrite_config, ) if clear_cache: - config_manager.cache.invalidate_all() - runner = RobocopFormatter(config_manager) + manager.cache.invalidate_all() + runner = RobocopFormatter(manager) return runner.run() @@ -764,23 +764,21 @@ def list_rules( silent=silent, target_version=target_version, ) - config_manager = config.ConfigManager(overwrite_config=overwrite_config) - runner = RobocopLinter(config_manager) - default_config = runner.config_manager.default_config + manager = config_manager.ConfigManager(overwrite_config=overwrite_config) if filter_pattern: filter_pattern = compile_rule_pattern(filter_pattern) - rules = filter_rules_by_pattern(default_config.linter.rules, filter_pattern) + rules = filter_rules_by_pattern(manager.default_config.linter.rules, filter_pattern) else: rules = filter_rules_by_category( - default_config.linter.rules, filter_category, default_config.linter.target_version + manager.default_config.linter.rules, filter_category, manager.default_config.linter.target_version ) severity_counter = {"E": 0, "W": 0, "I": 0} enabled = 0 for rule in rules: - is_enabled = rule.enabled and not rule.is_disabled(default_config.linter.target_version) + is_enabled = rule.enabled and not rule.is_disabled(manager.default_config.linter.target_version) enabled += int(is_enabled) if not silent: - console.print(rule.rule_short_description(default_config.linter.target_version)) + console.print(rule.rule_short_description(manager.default_config.linter.target_version)) severity_counter[rule.severity.value] += 1 configurable_rules_sum = sum(severity_counter.values()) plural = get_plural_form(configurable_rules_sum) @@ -815,8 +813,8 @@ def list_reports( console = Console(soft_wrap=True) linter_config = config.LinterConfig(reports=reports) overwrite_config = config.Config(linter=linter_config, silent=silent) - config_manager = config.ConfigManager(overwrite_config=overwrite_config) - runner = RobocopLinter(config_manager) + manager = config_manager.ConfigManager(overwrite_config=overwrite_config) + runner = RobocopLinter(manager) if not silent: console.print(print_reports(runner.reports, enabled)) # TODO: color etc @@ -865,8 +863,8 @@ def list_formatters( silent=silent, target_version=target_version, ) - config_manager = config.ConfigManager(overwrite_config=overwrite_config) - default_config = config_manager.default_config + manager = config_manager.ConfigManager(overwrite_config=overwrite_config) + default_config = manager.default_config if filter_category == filter_category.ALL: formatters = list(default_config.formatter.formatters.values()) elif filter_category == filter_category.ENABLED: @@ -896,15 +894,13 @@ def print_resource_documentation(name: Annotated[str, typer.Argument(help="Rule """Print formatter, rule or report documentation.""" # TODO load external from cli console = Console(soft_wrap=True) - config_manager = config.ConfigManager() + manager = config_manager.ConfigManager() - runner = RobocopLinter(config_manager) - default_config = runner.config_manager.default_config - if name in default_config.linter.rules: - console.print(default_config.linter.rules[name].description_with_configurables) + if name in manager.default_config.linter.rules: + console.print(manager.default_config.linter.rules[name].description_with_configurables) return - reports = load_reports(config_manager.default_config) + reports = load_reports(manager.default_config) if name in reports: docs = textwrap.dedent(reports[name].__doc__) console.print(docs) diff --git a/src/robocop/source_file.py b/src/robocop/source_file.py new file mode 100644 index 000000000..6edc11255 --- /dev/null +++ b/src/robocop/source_file.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Callable + +from robot.api import get_init_model, get_model, get_resource_model + +try: + from robot.api import Languages # RF 6.0 +except ImportError: + Languages = None + +from robocop.version_handling import LANG_SUPPORTED + +if TYPE_CHECKING: + from pathlib import Path + + from robot.parsing.model import File + + from robocop.config import Config + + +@dataclass +class SourceFile: + """ + Represents a source file with associated configuration, model, and content. + + Attributes: + path: The path to the source file on the filesystem. + config: The configuration settings associated with the source file. + model: An optional model associated with the source file. + source_lines: An optional list of lines representing the content of the source file. + + """ + + path: Path + config: Config + _model: File | None = None + _source_lines: list[str] | None = None + + @property + def model(self) -> File: + if self._model is None: + self._model = self._load_model() + return self._model + + def _load_model(self) -> File: + """Determine the correct model loader based on file type and loads it.""" + if "__init__" in self.path.name: + loader: Callable = get_init_model + elif self.path.suffix == ".resource": + loader: Callable = get_resource_model + else: + loader: Callable = get_model + + if LANG_SUPPORTED: + return loader(self.path, lang=self.config.languages) + return loader(self.path) diff --git a/src/robocop/linter/utils/version_matching.py b/src/robocop/version_handling.py similarity index 95% rename from src/robocop/linter/utils/version_matching.py rename to src/robocop/version_handling.py index 70f4b6f28..eeb4a694e 100644 --- a/src/robocop/linter/utils/version_matching.py +++ b/src/robocop/version_handling.py @@ -1,283 +1,291 @@ -from __future__ import annotations - -import itertools -import re -from functools import total_ordering -from typing import SupportsInt - -VERSION_PATTERN = r""" - v? - (?: - (?P[0-9]+(?:\.[0-9]+)*) # release segment - (?P
                                          # pre-release
-            [-_\.]?
-            (?P(a|b|c|rc|alpha|beta|pre|preview))
-            [-_\.]?
-            (?P[0-9]+)?
-        )?
-        (?P                                          # dev release
-            [-_\.]?
-            (?Pdev)
-            [-_\.]?
-            (?P[0-9]+)?
-        )?
-    )
-    (?:\+(?P[a-z0-9]+(?:[-_\.][a-z0-9]+)*))?       # local version
-"""
-
-
-def _get_comparison_key(release: tuple[int, ...]) -> tuple[int, ...]:
-    # When we compare a release version, we want to compare it with all the
-    # trailing zeros removed. So we'll use a reverse the list, drop all the now
-    # leading zeros until we come to something non-zero, then take the rest
-    # re-reverse it back into the correct order and make it a tuple and use
-    # that for our sorting key.
-    return tuple(reversed(list(itertools.dropwhile(lambda x: x == 0, reversed(release)))))
-
-
-def _parse_letter_version(letter: str, number: str | bytes | SupportsInt) -> tuple[str, int] | None:
-    if letter:
-        # We consider there to be an implicit 0 in a pre-release if there is
-        # not a numeral associated with it.
-        if number is None:
-            number = 0
-
-        # We normalize any letters to their lower case form
-        letter = letter.lower()
-
-        if letter == "alpha":
-            letter = "a"
-        elif letter == "beta":
-            letter = "b"
-        elif letter in ["c", "pre", "preview"]:
-            letter = "rc"
-
-        return letter, int(number)
-    return None
-
-
-@total_ordering
-class Version:
-    _version_pattern = re.compile(rf"^\s*{VERSION_PATTERN}\s*$", re.VERBOSE | re.IGNORECASE)
-
-    def __init__(self, version: str) -> None:
-        match = self._version_pattern.search(version)
-        self.release = tuple(int(i) for i in match.group("release").split("."))
-        self.pre = _parse_letter_version(match.group("pre_l"), match.group("pre_n"))
-        self._dev = _parse_letter_version(match.group("dev_l"), match.group("dev_n"))
-        self._key = _get_comparison_key(self.release)
-
-    def __lt__(self, other: Version) -> bool:
-        return self._key < other._key
-
-    def __eq__(self, other: Version) -> bool:
-        return self._key == other._key
-
-    def __hash__(self):
-        return hash(self._key)
-
-    def __str__(self) -> str:
-        return ".".join(str(x) for x in self.release)
-
-    @property
-    def public(self) -> str:
-        return str(self).split("+", 1)[0]
-
-    @property
-    def base_version(self) -> str:
-        parts = [".".join(str(x) for x in self.release)]
-        return "".join(parts)
-
-    @property
-    def major(self) -> int:
-        return self.release[0] if len(self.release) >= 1 else 0
-
-    @property
-    def minor(self) -> int:
-        return self.release[1] if len(self.release) >= 2 else 0
-
-    @property
-    def micro(self) -> int:
-        return self.release[2] if len(self.release) >= 3 else 0
-
-
-def _pad_version(left: list[str], right: list[str]) -> tuple[list, list]:
-    left_split, right_split = [], []
-
-    # Get the release segment of our versions
-    left_split.append(list(itertools.takewhile(lambda x: x.isdigit(), left)))
-    right_split.append(list(itertools.takewhile(lambda x: x.isdigit(), right)))
-
-    # Get the rest of our versions
-    left_split.append(left[len(left_split[0]) :])
-    right_split.append(right[len(right_split[0]) :])
-
-    # Insert our padding
-    left_split.insert(1, ["0"] * max(0, len(right_split[0]) - len(left_split[0])))
-    right_split.insert(1, ["0"] * max(0, len(left_split[0]) - len(right_split[0])))
-
-    return list(itertools.chain(*left_split)), list(itertools.chain(*right_split))
-
-
-_prefix_regex = re.compile(r"^([0-9]+)((?:a|b|c|rc)[0-9]+)$")
-
-
-def _version_split(version: str) -> list[str]:
-    result: list[str] = []
-    for item in version.split("."):
-        match = _prefix_regex.search(item)
-        if match:
-            result.extend(match.groups())
-        else:
-            result.append(item)
-    return result
-
-
-def _is_not_suffix(segment: str) -> bool:
-    return not any(segment.startswith(prefix) for prefix in ("dev", "a", "b", "rc"))
-
-
-class VersionSpecifier:
-    _regex_str = r"""
-        (?P(~=|==|!=|<=|>=|<|>))
-        (?P
-            (?:
-                # The (non)equality operators allow for wild card and local
-                # versions to be specified so we have to define these two
-                # operators separately to enable that.
-                (?<===|!=)            # Only match for equals and not equals
-
-                \s*
-                v?
-                [0-9]+(?:\.[0-9]+)*   # release
-                # You cannot use a wild card and a dev or local version
-                # together so group them with a | and make them optional.
-                (?:
-                    \.\*  # Wild card syntax of .*
-                )?
-            )
-            |
-            (?:
-                # The compatible operator requires at least two digits in the
-                # release segment.
-                (?<=~=)               # Only match for the compatible operator
-
-                \s*
-                v?
-                [0-9]+(?:\.[0-9]+)+   # release  (We have a + instead of a *)
-            )
-            |
-            (?:
-                # All other operators only allow a sub set of what the
-                # (non)equality operators do. Specifically they do not allow
-                # local versions to be specified nor do they allow the prefix
-                # matching wild cards.
-                (? None:
-        self._spec: tuple[str, str] = self._parse_spec(spec)
-        self._operators = {
-            "~=": self._compare_compatible,
-            "==": self._compare_equal,
-            "!=": self._compare_not_equal,
-            "<=": self._compare_less_than_equal,
-            ">=": self._compare_greater_than_equal,
-            "<": self._compare_less_than,
-            ">": self._compare_greater_than,
-        }
-
-    def _parse_spec(self, spec: str) -> tuple[str, str]:
-        match = self._regex.search(spec)
-        if not match:
-            raise ValueError(f"Invalid specifier: '{spec}'")
-
-        return (
-            match.group("operator").strip(),
-            match.group("version").strip(),
-        )
-
-    def __contains__(self, item: str) -> bool:
-        normalized_item = self._coerce_version(item)
-        return self._operators[self.operator](normalized_item, self.version)
-
-    def _coerce_version(self, version: Version | str) -> Version:
-        if not isinstance(version, Version):
-            version = Version(version)
-        return version
-
-    @property
-    def operator(self) -> str:
-        return self._spec[0]
-
-    @property
-    def version(self) -> str:
-        return self._spec[1]
-
-    def _compare_compatible(self, prospective: Version, spec: str) -> bool:
-        # Compatible releases have an equivalent combination of >= and ==. That
-        # is that ~=2.2 is equivalent to >=2.2,==2.*. This allows us to
-        # implement this in terms of the other specifiers instead of
-        # implementing it ourselves. The only thing we need to do is construct
-        # the other specifiers.
-
-        # We want everything but the last item in the version, but we want to
-        # ignore suffix segments.
-        prefix = ".".join(list(itertools.takewhile(_is_not_suffix, _version_split(spec)))[:-1])
-
-        # Add the prefix notation to the end of our string
-        prefix += ".*"
-
-        return self._compare_greater_than_equal(prospective, spec) and self._compare_equal(prospective, prefix)
-
-    def _compare_equal(self, prospective: Version, spec: str) -> bool:
-        if spec.endswith(".*"):
-            # In the case of prefix matching we want to ignore local segment.
-            prospective = Version(prospective.public)
-            # Split the spec out by dots, and pretend that there is an implicit
-            # dot in between a release segment and a pre-release segment.
-            split_spec = _version_split(spec[:-2])  # Remove the trailing .*
-
-            # Split the prospective version out by dots, and pretend that there
-            # is an implicit dot in between a release segment and a pre-release
-            # segment.
-            split_prospective = _version_split(str(prospective))
-
-            # Shorten the prospective version to be the same length as the spec
-            # so that we can determine if the specifier is a prefix of the
-            # prospective version or not.
-            shortened_prospective = split_prospective[: len(split_spec)]
-
-            # Pad out our two sides with zeros so that they both equal the same
-            # length.
-            padded_spec, padded_prospective = _pad_version(split_spec, shortened_prospective)
-
-            return padded_prospective == padded_spec
-        # Convert our spec string into a Version
-        spec_version = Version(spec)
-        return prospective == spec_version
-
-    def _compare_not_equal(self, prospective: Version, spec: str) -> bool:
-        return not self._compare_equal(prospective, spec)
-
-    def _compare_less_than_equal(self, prospective: Version, spec: str) -> bool:
-        return Version(prospective.public) <= Version(spec)
-
-    def _compare_greater_than_equal(self, prospective: Version, spec: str) -> bool:
-        return Version(prospective.public) >= Version(spec)
-
-    def _compare_less_than(self, prospective: Version, spec_str: str) -> bool:
-        return prospective < Version(spec_str)
-
-    def _compare_greater_than(self, prospective: Version, spec_str: str) -> bool:
-        return prospective > Version(spec_str)
+from __future__ import annotations
+
+import itertools
+import re
+from functools import total_ordering
+from typing import SupportsInt
+
+from robot.version import VERSION as RF_VERSION
+
+VERSION_PATTERN = r"""
+    v?
+    (?:
+        (?P[0-9]+(?:\.[0-9]+)*)                  # release segment
+        (?P
                                          # pre-release
+            [-_\.]?
+            (?P(a|b|c|rc|alpha|beta|pre|preview))
+            [-_\.]?
+            (?P[0-9]+)?
+        )?
+        (?P                                          # dev release
+            [-_\.]?
+            (?Pdev)
+            [-_\.]?
+            (?P[0-9]+)?
+        )?
+    )
+    (?:\+(?P[a-z0-9]+(?:[-_\.][a-z0-9]+)*))?       # local version
+"""
+
+
+def _get_comparison_key(release: tuple[int, ...]) -> tuple[int, ...]:
+    # When we compare a release version, we want to compare it with all the
+    # trailing zeros removed. So we'll use a reverse the list, drop all the now
+    # leading zeros until we come to something non-zero, then take the rest
+    # re-reverse it back into the correct order and make it a tuple and use
+    # that for our sorting key.
+    return tuple(reversed(list(itertools.dropwhile(lambda x: x == 0, reversed(release)))))
+
+
+def _parse_letter_version(letter: str, number: str | bytes | SupportsInt) -> tuple[str, int] | None:
+    if letter:
+        # We consider there to be an implicit 0 in a pre-release if there is
+        # not a numeral associated with it.
+        if number is None:
+            number = 0
+
+        # We normalize any letters to their lower case form
+        letter = letter.lower()
+
+        if letter == "alpha":
+            letter = "a"
+        elif letter == "beta":
+            letter = "b"
+        elif letter in ["c", "pre", "preview"]:
+            letter = "rc"
+
+        return letter, int(number)
+    return None
+
+
+def _pad_version(left: list[str], right: list[str]) -> tuple[list, list]:
+    left_split, right_split = [], []
+
+    # Get the release segment of our versions
+    left_split.append(list(itertools.takewhile(lambda x: x.isdigit(), left)))
+    right_split.append(list(itertools.takewhile(lambda x: x.isdigit(), right)))
+
+    # Get the rest of our versions
+    left_split.append(left[len(left_split[0]) :])
+    right_split.append(right[len(right_split[0]) :])
+
+    # Insert our padding
+    left_split.insert(1, ["0"] * max(0, len(right_split[0]) - len(left_split[0])))
+    right_split.insert(1, ["0"] * max(0, len(left_split[0]) - len(right_split[0])))
+
+    return list(itertools.chain(*left_split)), list(itertools.chain(*right_split))
+
+
+_prefix_regex = re.compile(r"^([0-9]+)((?:a|b|c|rc)[0-9]+)$")
+
+
+def _version_split(version: str) -> list[str]:
+    result: list[str] = []
+    for item in version.split("."):
+        match = _prefix_regex.search(item)
+        if match:
+            result.extend(match.groups())
+        else:
+            result.append(item)
+    return result
+
+
+def _is_not_suffix(segment: str) -> bool:
+    return not any(segment.startswith(prefix) for prefix in ("dev", "a", "b", "rc"))
+
+
+class VersionSpecifier:
+    _regex_str = r"""
+        (?P(~=|==|!=|<=|>=|<|>))
+        (?P
+            (?:
+                # The (non)equality operators allow for wild card and local
+                # versions to be specified so we have to define these two
+                # operators separately to enable that.
+                (?<===|!=)            # Only match for equals and not equals
+
+                \s*
+                v?
+                [0-9]+(?:\.[0-9]+)*   # release
+                # You cannot use a wild card and a dev or local version
+                # together so group them with a | and make them optional.
+                (?:
+                    \.\*  # Wild card syntax of .*
+                )?
+            )
+            |
+            (?:
+                # The compatible operator requires at least two digits in the
+                # release segment.
+                (?<=~=)               # Only match for the compatible operator
+
+                \s*
+                v?
+                [0-9]+(?:\.[0-9]+)+   # release  (We have a + instead of a *)
+            )
+            |
+            (?:
+                # All other operators only allow a sub set of what the
+                # (non)equality operators do. Specifically they do not allow
+                # local versions to be specified nor do they allow the prefix
+                # matching wild cards.
+                (? None:
+        self._spec: tuple[str, str] = self._parse_spec(spec)
+        self._operators = {
+            "~=": self._compare_compatible,
+            "==": self._compare_equal,
+            "!=": self._compare_not_equal,
+            "<=": self._compare_less_than_equal,
+            ">=": self._compare_greater_than_equal,
+            "<": self._compare_less_than,
+            ">": self._compare_greater_than,
+        }
+
+    def _parse_spec(self, spec: str) -> tuple[str, str]:
+        match = self._regex.search(spec)
+        if not match:
+            raise ValueError(f"Invalid specifier: '{spec}'")
+
+        return (
+            match.group("operator").strip(),
+            match.group("version").strip(),
+        )
+
+    def __contains__(self, item: str) -> bool:
+        normalized_item = self._coerce_version(item)
+        return self._operators[self.operator](normalized_item, self.version)
+
+    def _coerce_version(self, version: Version | str) -> Version:
+        if not isinstance(version, Version):
+            version = Version(version)
+        return version
+
+    @property
+    def operator(self) -> str:
+        return self._spec[0]
+
+    @property
+    def version(self) -> str:
+        return self._spec[1]
+
+    def _compare_compatible(self, prospective: Version, spec: str) -> bool:
+        # Compatible releases have an equivalent combination of >= and ==. That
+        # is that ~=2.2 is equivalent to >=2.2,==2.*. This allows us to
+        # implement this in terms of the other specifiers instead of
+        # implementing it ourselves. The only thing we need to do is construct
+        # the other specifiers.
+
+        # We want everything but the last item in the version, but we want to
+        # ignore suffix segments.
+        prefix = ".".join(list(itertools.takewhile(_is_not_suffix, _version_split(spec)))[:-1])
+
+        # Add the prefix notation to the end of our string
+        prefix += ".*"
+
+        return self._compare_greater_than_equal(prospective, spec) and self._compare_equal(prospective, prefix)
+
+    def _compare_equal(self, prospective: Version, spec: str) -> bool:
+        if spec.endswith(".*"):
+            # In the case of prefix matching we want to ignore local segment.
+            prospective = Version(prospective.public)
+            # Split the spec out by dots, and pretend that there is an implicit
+            # dot in between a release segment and a pre-release segment.
+            split_spec = _version_split(spec[:-2])  # Remove the trailing .*
+
+            # Split the prospective version out by dots, and pretend that there
+            # is an implicit dot in between a release segment and a pre-release
+            # segment.
+            split_prospective = _version_split(str(prospective))
+
+            # Shorten the prospective version to be the same length as the spec
+            # so that we can determine if the specifier is a prefix of the
+            # prospective version or not.
+            shortened_prospective = split_prospective[: len(split_spec)]
+
+            # Pad out our two sides with zeros so that they both equal the same
+            # length.
+            padded_spec, padded_prospective = _pad_version(split_spec, shortened_prospective)
+
+            return padded_prospective == padded_spec
+        # Convert our spec string into a Version
+        spec_version = Version(spec)
+        return prospective == spec_version
+
+    def _compare_not_equal(self, prospective: Version, spec: str) -> bool:
+        return not self._compare_equal(prospective, spec)
+
+    def _compare_less_than_equal(self, prospective: Version, spec: str) -> bool:
+        return Version(prospective.public) <= Version(spec)
+
+    def _compare_greater_than_equal(self, prospective: Version, spec: str) -> bool:
+        return Version(prospective.public) >= Version(spec)
+
+    def _compare_less_than(self, prospective: Version, spec_str: str) -> bool:
+        return prospective < Version(spec_str)
+
+    def _compare_greater_than(self, prospective: Version, spec_str: str) -> bool:
+        return prospective > Version(spec_str)
+
+
+@total_ordering
+class Version:
+    _version_pattern = re.compile(rf"^\s*{VERSION_PATTERN}\s*$", re.VERBOSE | re.IGNORECASE)
+
+    def __init__(self, version: str) -> None:
+        match = self._version_pattern.search(version)
+        self.release = tuple(int(i) for i in match.group("release").split("."))
+        self.pre = _parse_letter_version(match.group("pre_l"), match.group("pre_n"))
+        self._dev = _parse_letter_version(match.group("dev_l"), match.group("dev_n"))
+        self._key = _get_comparison_key(self.release)
+
+    def __lt__(self, other: Version) -> bool:
+        return self._key < other._key
+
+    def __eq__(self, other: Version) -> bool:
+        return self._key == other._key
+
+    def __hash__(self):
+        return hash(self._key)
+
+    def __str__(self) -> str:
+        return ".".join(str(x) for x in self.release)
+
+    @property
+    def public(self) -> str:
+        return str(self).split("+", 1)[0]
+
+    @property
+    def base_version(self) -> str:
+        parts = [".".join(str(x) for x in self.release)]
+        return "".join(parts)
+
+    @property
+    def major(self) -> int:
+        return self.release[0] if len(self.release) >= 1 else 0
+
+    @property
+    def minor(self) -> int:
+        return self.release[1] if len(self.release) >= 2 else 0
+
+    @property
+    def micro(self) -> int:
+        return self.release[2] if len(self.release) >= 3 else 0
+
+
+ROBOT_VERSION = Version(RF_VERSION)
+INLINE_IF_SUPPORTED = ROBOT_VERSION.major >= 5
+LANG_SUPPORTED = ROBOT_VERSION.major >= 6
+TYPE_SUPPORTED = Version("7.3") <= ROBOT_VERSION
diff --git a/tests/config/test_config_manager.py b/tests/config/test_config_manager.py
index 5a523caed..ed9696f42 100644
--- a/tests/config/test_config_manager.py
+++ b/tests/config/test_config_manager.py
@@ -9,14 +9,14 @@
 from robocop.config import (
     CacheConfig,
     Config,
-    ConfigManager,
     FileFiltersOptions,
     FormatterConfig,
     LinterConfig,
 )
-from robocop.formatter.utils.misc import ROBOT_VERSION
+from robocop.config_manager import ConfigManager
 from robocop.linter.rules import RuleSeverity
 from robocop.linter.runner import RobocopLinter
+from robocop.version_handling import ROBOT_VERSION
 from tests import working_directory
 
 
@@ -77,7 +77,7 @@ def overwrite_config() -> Config:
 def get_sources_and_configs(config_dir: Path, **kwargs) -> dict[Path, Config]:
     with working_directory(config_dir):
         config_manager = ConfigManager(**kwargs)
-        return dict(config_manager.paths)
+        return {source.path: source.config for source in config_manager.paths}
 
 
 class TestConfigFinder:
diff --git a/tests/config/test_extend_config.py b/tests/config/test_extend_config.py
index 7fc73e365..186ee65ec 100644
--- a/tests/config/test_extend_config.py
+++ b/tests/config/test_extend_config.py
@@ -5,7 +5,7 @@
 import pytest
 import typer
 
-from robocop.config import ConfigManager
+from robocop.config_manager import ConfigManager
 from tests import working_directory
 
 DATA_DIR = Path(__file__).parent / "test_data"
diff --git a/tests/formatter/__init__.py b/tests/formatter/__init__.py
index 7f4573316..c511f595e 100644
--- a/tests/formatter/__init__.py
+++ b/tests/formatter/__init__.py
@@ -115,6 +115,7 @@ def compare_file(self, actual_name: str, expected_name: str | None = None):
             pytest.fail(f"File {actual_name} is not same as expected")
 
     def enabled_in_version(self, target_version: str | None) -> bool:
+        # TODO: this can potentially be solved with robocop version handling instead
         if target_version and ROBOT_VERSION not in SpecifierSet(target_version, prereleases=True):
             return False
         if self.FORMATTER_NAME in VERSION_MATRIX:
diff --git a/tests/formatter/formatters/NormalizeSeparators/test_formatter.py b/tests/formatter/formatters/NormalizeSeparators/test_formatter.py
index 7a5e955da..4909f4d81 100644
--- a/tests/formatter/formatters/NormalizeSeparators/test_formatter.py
+++ b/tests/formatter/formatters/NormalizeSeparators/test_formatter.py
@@ -1,6 +1,6 @@
 import pytest
 
-from robocop.formatter.utils.misc import ROBOT_VERSION
+from robocop.version_handling import ROBOT_VERSION
 from tests.formatter import FormatterAcceptanceTest
 
 
diff --git a/tests/formatter/test_disablers.py b/tests/formatter/test_disablers.py
index 2434333ed..2bb48c3ce 100644
--- a/tests/formatter/test_disablers.py
+++ b/tests/formatter/test_disablers.py
@@ -5,7 +5,7 @@
 from robot.api import get_model
 
 from robocop.formatter.disablers import DisabledLines, RegisterDisablers
-from robocop.formatter.utils.misc import ROBOT_VERSION
+from robocop.version_handling import ROBOT_VERSION
 
 TEST_DATA = Path(__file__).parent / "test_data" / "disablers"
 
diff --git a/tests/linter/cli/conftest.py b/tests/linter/cli/conftest.py
index a8c6ca9c4..b154be0b8 100644
--- a/tests/linter/cli/conftest.py
+++ b/tests/linter/cli/conftest.py
@@ -1,6 +1,6 @@
 import pytest
 
-from robocop.config import ConfigManager
+from robocop.config_manager import ConfigManager
 from robocop.linter.runner import RobocopLinter
 
 
diff --git a/tests/linter/cli/test_list_rules.py b/tests/linter/cli/test_list_rules.py
index b9ad189b9..ddc59de92 100644
--- a/tests/linter/cli/test_list_rules.py
+++ b/tests/linter/cli/test_list_rules.py
@@ -6,8 +6,8 @@
 
 from robocop.config import TargetVersion
 from robocop.linter.rules import Rule, RuleFilter, RuleSeverity, VisitorChecker
-from robocop.linter.utils.misc import ROBOT_VERSION
 from robocop.run import list_rules
+from robocop.version_handling import ROBOT_VERSION
 from tests import working_directory
 
 TEST_DATA = Path(__file__).parent.parent / "test_data" / "custom_rules"
@@ -158,7 +158,10 @@ def test_list_rule(
         """List rules with default options."""
         for checker in (msg_0101_checker, non_default_rule_checker, deprecated_rules_checker):
             empty_linter.config_manager.default_config.linter.register_checker(checker)
-        with working_directory(tmp_path), patch("robocop.run.RobocopLinter", MagicMock(return_value=empty_linter)):
+        with (
+            working_directory(tmp_path),
+            patch("robocop.config_manager.ConfigManager", MagicMock(return_value=empty_linter.config_manager)),
+        ):
             list_rules()
         out, _ = capsys.readouterr()
         assert (
@@ -179,7 +182,7 @@ def test_list_disabled_rule(self, empty_linter, msg_0101_checker, disabled_for_4
         else:
             enabled_count = 1
             enabled_for = "enabled"
-        with patch("robocop.run.RobocopLinter", MagicMock(return_value=empty_linter)):
+        with patch("robocop.config_manager.ConfigManager", MagicMock(return_value=empty_linter.config_manager)):
             list_rules(filter_pattern="*")
         out, _ = capsys.readouterr()
         assert (
@@ -195,7 +198,7 @@ def test_list_filter_enabled(self, empty_linter, msg_0101_checker, msg_0102_0204
         empty_linter.config_manager.default_config.linter.exclude_rules = {"0102", "0204"}
         empty_linter.config_manager.default_config.linter.check_for_disabled_rules()
 
-        with patch("robocop.run.RobocopLinter", MagicMock(return_value=empty_linter)):
+        with patch("robocop.config_manager.ConfigManager", MagicMock(return_value=empty_linter.config_manager)):
             list_rules(filter_category=RuleFilter.ENABLED)
         out, _ = capsys.readouterr()
         assert (
@@ -212,7 +215,7 @@ def test_list_filter_disabled(
         empty_linter.config_manager.default_config.linter.register_checker(deprecated_rules_checker)
         empty_linter.config_manager.default_config.linter.exclude_rules = {"0102", "0204"}
         empty_linter.config_manager.default_config.linter.check_for_disabled_rules()
-        with patch("robocop.run.RobocopLinter", MagicMock(return_value=empty_linter)):
+        with patch("robocop.config_manager.ConfigManager", MagicMock(return_value=empty_linter.config_manager)):
             list_rules(filter_category=RuleFilter.DISABLED)
         out, _ = capsys.readouterr()
         assert (
@@ -229,7 +232,7 @@ def test_list_filter_deprecated(
         empty_linter.config_manager.default_config.linter.register_checker(msg_0102_0204_checker)
         empty_linter.config_manager.default_config.linter.register_checker(deprecated_rules_checker)
         empty_linter.config_manager.default_config.linter.exclude_rules = {"0102", "0204"}
-        with patch("robocop.run.RobocopLinter", MagicMock(return_value=empty_linter)):
+        with patch("robocop.config_manager.ConfigManager", MagicMock(return_value=empty_linter.config_manager)):
             list_rules(filter_category=RuleFilter.DEPRECATED)
         out, _ = capsys.readouterr()
         assert (
@@ -244,7 +247,7 @@ def test_multiple_checkers(self, empty_linter, msg_0101_checker, msg_0102_0204_c
         empty_linter.config_manager.default_config.linter.register_checker(msg_0102_0204_checker)
         empty_linter.config_manager.default_config.linter.exclude_rules = {"0102", "0204"}
         empty_linter.config_manager.default_config.linter.check_for_disabled_rules()
-        with patch("robocop.run.RobocopLinter", MagicMock(return_value=empty_linter)):
+        with patch("robocop.config_manager.ConfigManager", MagicMock(return_value=empty_linter.config_manager)):
             list_rules(filter_pattern="*")
         out, _ = capsys.readouterr()
         exp_msg = (
@@ -262,7 +265,7 @@ def test_list_filtered(
         empty_linter.config_manager.default_config.linter.register_checker(deprecated_rules_checker)
         empty_linter.config_manager.default_config.linter.exclude_rules = {"0102", "0204"}
         empty_linter.config_manager.default_config.linter.check_for_disabled_rules()
-        with patch("robocop.run.RobocopLinter", MagicMock(return_value=empty_linter)):
+        with patch("robocop.config_manager.ConfigManager", MagicMock(return_value=empty_linter.config_manager)):
             list_rules(filter_pattern="01*")
         out, _ = capsys.readouterr()
         exp_msg = (
@@ -279,7 +282,7 @@ def test_list_rule_filtered_and_non_default(
     ):
         empty_linter.config_manager.default_config.linter.register_checker(msg_0101_checker)
         empty_linter.config_manager.default_config.linter.register_checker(non_default_rule_checker)
-        with patch("robocop.run.RobocopLinter", MagicMock(return_value=empty_linter)):
+        with patch("robocop.config_manager.ConfigManager", MagicMock(return_value=empty_linter.config_manager)):
             list_rules(**config)
         out, _ = capsys.readouterr()
         assert (
@@ -292,7 +295,7 @@ def test_list_rule_filtered_and_non_default(
     def test_list_with_target_version(self, empty_linter, msg_0101_checker, future_checker, capsys):
         empty_linter.config_manager.default_config.linter.register_checker(msg_0101_checker)
         empty_linter.config_manager.default_config.linter.register_checker(future_checker)
-        with patch("robocop.run.RobocopLinter", MagicMock(return_value=empty_linter)):
+        with patch("robocop.config_manager.ConfigManager", MagicMock(return_value=empty_linter.config_manager)):
             list_rules(target_version=TargetVersion(str(ROBOT_VERSION.major)))
         out, _ = capsys.readouterr()
         expected = textwrap.dedent("""
@@ -311,7 +314,7 @@ def test_list_with_target_version(self, empty_linter, msg_0101_checker, future_c
     #         str(TEST_DATA / "disabled_by_default" / "external_rule2.py"),
     #     }
     #     empty_linter.load_checkers()
-    #     with patch("robocop.run.RobocopLinter", MagicMock(return_value=empty_linter)):
+    #     with patch("robocop.config_manager.ConfigManager", MagicMock(return_value=empty_linter.config_manager)):
     #         list_rules(filter_pattern="*")
     #     out, _ = capsys.readouterr()
     #     exp_msg = (
@@ -328,7 +331,7 @@ def test_list_with_target_version(self, empty_linter, msg_0101_checker, future_c
     #     empty_linter.config.include = {"1102"}
     #     empty_linter.load_checkers()
     #     empty_linter.check_for_disabled_rules()
-    #     with patch("robocop.run.RobocopLinter", MagicMock(return_value=empty_linter)):
+    #     with patch("robocop.config_manager.ConfigManager", MagicMock(return_value=empty_linter.config_manager)):
     #         list_rules(filter_pattern="*")
     #     out, _ = capsys.readouterr()
     #     exp_msg = (
diff --git a/tests/linter/rules/custom_tests/project_checker/external_project_checker/__init__.py b/tests/linter/rules/custom_tests/project_checker/external_project_checker/__init__.py
index 712948ce7..96e928ddb 100644
--- a/tests/linter/rules/custom_tests/project_checker/external_project_checker/__init__.py
+++ b/tests/linter/rules/custom_tests/project_checker/external_project_checker/__init__.py
@@ -1,4 +1,4 @@
-from robocop.config import ConfigManager
+from robocop.config_manager import ConfigManager
 from robocop.linter.rules import ProjectChecker, Rule, RuleSeverity
 
 
diff --git a/tests/linter/test_disablers.py b/tests/linter/test_disablers.py
index 76ea8cb72..9edd4d65f 100644
--- a/tests/linter/test_disablers.py
+++ b/tests/linter/test_disablers.py
@@ -6,8 +6,7 @@
 from robocop.linter.diagnostics import Diagnostic
 from robocop.linter.rules.lengths import LineTooLongRule
 from robocop.linter.utils.disablers import DisablersFinder
-from robocop.linter.utils.misc import ROBOT_VERSION
-from robocop.linter.utils.version_matching import Version
+from robocop.version_handling import ROBOT_VERSION, Version
 
 
 @pytest.fixture
diff --git a/tests/linter/utils/__init__.py b/tests/linter/utils/__init__.py
index f4f07e4b1..1798eebed 100644
--- a/tests/linter/utils/__init__.py
+++ b/tests/linter/utils/__init__.py
@@ -14,9 +14,8 @@
 import pytest
 from rich.console import Console
 
-from robocop.linter.utils.misc import ROBOT_VERSION
-from robocop.linter.utils.version_matching import VersionSpecifier
 from robocop.run import check_files, check_project
+from robocop.version_handling import ROBOT_VERSION, VersionSpecifier
 from tests import working_directory
 
 if TYPE_CHECKING:
diff --git a/tests/linter/utils/test_version_matching.py b/tests/linter/utils/test_version_matching.py
index f22f3a79f..9f68f7254 100644
--- a/tests/linter/utils/test_version_matching.py
+++ b/tests/linter/utils/test_version_matching.py
@@ -1,6 +1,6 @@
 import pytest
 
-from robocop.linter.utils.version_matching import Version, VersionSpecifier
+from robocop.version_handling import Version, VersionSpecifier
 
 
 @pytest.mark.parametrize(
diff --git a/tests/performance/generate_reports.py b/tests/performance/generate_reports.py
index bb6bb57a6..b46c481e1 100644
--- a/tests/performance/generate_reports.py
+++ b/tests/performance/generate_reports.py
@@ -15,12 +15,19 @@
 
 from jinja2 import Environment, FileSystemLoader
 
-from robocop import __version__, config
+from robocop import __version__
 from robocop.formatter.formatters import FORMATTERS
-from robocop.linter.utils.version_matching import Version
 from robocop.run import check_files, format_files
 from tests import working_directory
 
+try:
+    from robocop.config_manager import ConfigManager
+    from robocop.version_handling import Version
+except ImportError:  # < 7.3.0
+    from robocop.config import ConfigManager
+    from robocop.linter.utils.misc import Version
+
+
 LINTER_TESTS_DIR = Path(__file__).parent.parent / "linter"
 TEST_DATA = Path(__file__).parent / "test_data"
 ROBOCOP_VERSION = Version(__version__)
@@ -79,7 +86,7 @@ def project_traversing_report() -> int:
     """
     main_dir = Path(__file__).parent.parent.parent
     with working_directory(main_dir):
-        config_manager = config.ConfigManager(
+        config_manager = ConfigManager(
             sources=["tests/linter"],
             config=None,
             root=None,
@@ -90,7 +97,7 @@ def project_traversing_report() -> int:
             overwrite_config=None,
         )
         files_count = 0
-        for _source, _config in config_manager.paths:
+        for _source_file in config_manager.paths:
             files_count += 1
     return files_count
 
diff --git a/tests/test_cli.py b/tests/test_cli.py
index ccebe3fba..a789b3326 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -7,8 +7,8 @@
 from typer.testing import CliRunner
 
 from robocop import __version__
-from robocop.linter.utils.misc import ROBOT_VERSION
 from robocop.run import app
+from robocop.version_handling import ROBOT_VERSION
 from tests import working_directory