diff --git a/package.json b/package.json index a3d61c263..fb4fdbd8b 100644 --- a/package.json +++ b/package.json @@ -1300,6 +1300,30 @@ "default": true, "description": "Enable/disable whether Robot Framework tests and tasks are integrated into the VSCode Test/Test Explorer view.", "scope": "resource" + }, + "robotcode.testExplorer.fastDiscovery.enabled": { + "type": "boolean", + "default": false, + "description": "Experimental optimization for very large workspaces. Prefilters files containing Test Cases/Tasks before full discovery.", + "scope": "resource" + }, + "robotcode.testExplorer.fastDiscovery.prefilterCommand": { + "type": "string", + "enum": [ + "auto", + "gitGrep", + "grep", + "none" + ], + "default": "auto", + "markdownDescription": "Selects prefilter command for fast discovery. `auto` tries `git grep` first, then `grep`, and falls back to normal discovery if unavailable. `none` disables prefiltering.", + "scope": "resource" + }, + "robotcode.testExplorer.fastDiscovery.command.enabled": { + "type": "boolean", + "default": false, + "description": "Use `robotcode discover fast` for workspace discovery when fast discovery prefiltering is enabled. Automatically falls back to full discovery if unsupported options are detected.", + "scope": "resource" } } } diff --git a/packages/language_server/src/robotcode/language_server/common/parts/diagnostics.py b/packages/language_server/src/robotcode/language_server/common/parts/diagnostics.py index 1aa56e099..e08ea55b2 100644 --- a/packages/language_server/src/robotcode/language_server/common/parts/diagnostics.py +++ b/packages/language_server/src/robotcode/language_server/common/parts/diagnostics.py @@ -349,7 +349,15 @@ def run_workspace_diagnostics(self) -> None: self._break_diagnostics_loop_event.clear() documents = sorted( - [doc for doc in self.parent.documents.documents if self._doc_need_update(doc)], + [ + doc + for doc in self.parent.documents.documents + if self._doc_need_update(doc) + and ( + doc.opened_in_editor + or self.get_diagnostics_mode(doc.uri) == DiagnosticsMode.WORKSPACE + ) + ], key=lambda d: not d.opened_in_editor, ) @@ -436,7 +444,7 @@ def run_workspace_diagnostics(self) -> None: documents_to_collect = [ doc for doc in documents - if doc.opened_in_editor or self.get_diagnostics_mode(document.uri) == DiagnosticsMode.WORKSPACE + if doc.opened_in_editor or self.get_diagnostics_mode(doc.uri) == DiagnosticsMode.WORKSPACE ] with self._logger.measure_time( diff --git a/packages/language_server/src/robotcode/language_server/robotframework/parts/robot_workspace.py b/packages/language_server/src/robotcode/language_server/robotframework/parts/robot_workspace.py index 2fef82926..b1226a029 100644 --- a/packages/language_server/src/robotcode/language_server/robotframework/parts/robot_workspace.py +++ b/packages/language_server/src/robotcode/language_server/robotframework/parts/robot_workspace.py @@ -58,6 +58,16 @@ def load_workspace_documents(self, sender: Any) -> None: for folder in self.parent.workspace.workspace_folders: config = self.parent.workspace.get_configuration(RobotCodeConfig, folder.uri) + if config.analysis.diagnostic_mode != DiagnosticsMode.WORKSPACE: + self._logger.debug( + lambda: ( + f"Skip loading workspace documents for {folder.uri.to_path()} " + f"because analysis.diagnosticMode={config.analysis.diagnostic_mode.value!r}" + ), + context_name="load_workspace_documents", + ) + continue + extensions = [ROBOT_FILE_EXTENSION, RESOURCE_FILE_EXTENSION] exclude_patterns = [ diff --git a/packages/robot/src/robotcode/robot/config/utils.py b/packages/robot/src/robotcode/robot/config/utils.py index 112d5769e..2ca102958 100644 --- a/packages/robot/src/robotcode/robot/config/utils.py +++ b/packages/robot/src/robotcode/robot/config/utils.py @@ -1,3 +1,4 @@ +import os from pathlib import Path from typing import Callable, Optional, Sequence, Tuple, Union @@ -72,7 +73,20 @@ def get_config_files( else: verbose_callback("No configuration files found.") - user_config = get_user_config_file(verbose_callback=verbose_callback) + disable_user_config_create = os.getenv("ROBOTCODE_DISABLE_USER_CONFIG_CREATE", "").lower() in [ + "on", + "1", + "yes", + "true", + ] + + if disable_user_config_create and verbose_callback: + verbose_callback( + "Automatic creation of user configuration is disabled by " + "ROBOTCODE_DISABLE_USER_CONFIG_CREATE." + ) + + user_config = get_user_config_file(create=not disable_user_config_create, verbose_callback=verbose_callback) return ( [ diff --git a/packages/runner/src/robotcode/runner/cli/discover/discover.py b/packages/runner/src/robotcode/runner/cli/discover/discover.py index 2eb4be817..67c9e7c7c 100644 --- a/packages/runner/src/robotcode/runner/cli/discover/discover.py +++ b/packages/runner/src/robotcode/runner/cli/discover/discover.py @@ -1,7 +1,10 @@ +import json import os import platform import re import sys +import time +from fnmatch import fnmatchcase from collections import defaultdict from dataclasses import dataclass from io import IOBase @@ -18,10 +21,11 @@ ) import click +from robot.api import Token, get_tokens import robot.running.model as running_model from robot.conf import RobotSettings from robot.errors import DATA_ERROR, INFO_PRINTED, DataError, Information -from robot.model import ModelModifier, TestCase, TestSuite +from robot.model import ModelModifier, TagPatterns, TestCase, TestSuite from robot.model.visitor import SuiteVisitor from robot.output import LOGGER, Message from robot.running.builder import TestSuiteBuilder @@ -39,7 +43,7 @@ ) from robotcode.core.uri import Uri from robotcode.core.utils.cli import show_hidden_arguments -from robotcode.core.utils.dataclasses import CamelSnakeMixin, from_json +from robotcode.core.utils.dataclasses import CamelSnakeMixin, as_json from robotcode.core.utils.path import normalized_path from robotcode.plugin import ( Application, @@ -62,7 +66,14 @@ def __init__(self, *args: Any, error_message: str, **kwargs: Any) -> None: _stdin_data: Optional[Dict[Uri, str]] = None +_stdin_candidates: Optional[List[str]] = None _app: Optional[Application] = None +_discover_log_visited_files = os.getenv("ROBOTCODE_DISCOVER_LOG_VISITED_FILES", "").lower() in ["on", "1", "yes", "true"] +_FAST_DISCOVERY_UNSUPPORTED_OPTION_PREFIXES = ( + "--parser", + "--prerunmodifier", + "-PARSER", +) def _patch() -> None: @@ -235,6 +246,424 @@ class Statistics(CamelSnakeMixin): tasks: int = 0 +def _fast_match(value: str, pattern: str) -> bool: + return fnmatchcase(normalize(value, ignore="_"), normalize(pattern, ignore="_")) + + +def _get_robot_option_values(robot_options_and_args: Tuple[str, ...], *option_names: str) -> List[str]: + result: List[str] = [] + option_names_set = set(option_names) + i = 0 + while i < len(robot_options_and_args): + arg = robot_options_and_args[i] + if arg in option_names_set: + if i + 1 < len(robot_options_and_args): + result.append(robot_options_and_args[i + 1]) + i += 2 + continue + else: + for name in option_names: + if arg.startswith(f"{name}="): + result.append(arg[len(name) + 1 :]) + break + i += 1 + return result + + +def _has_fast_discovery_unsupported_options(cmd_options: List[str], robot_options_and_args: Tuple[str, ...]) -> Optional[str]: + all_options = [*cmd_options, *robot_options_and_args] + for option in all_options: + option_l = option.lower() + if any( + option_l == prefix.lower() or option_l.startswith(f"{prefix.lower()}=") + for prefix in _FAST_DISCOVERY_UNSUPPORTED_OPTION_PREFIXES + ): + return option + return None + + +def _get_fast_discovery_suffixes(cmd_options: List[str], robot_options_and_args: Tuple[str, ...]) -> Set[str]: + extensions = _get_robot_option_values(tuple([*cmd_options, *robot_options_and_args]), "--extension", "-F") + suffixes = {f".{e.strip().lstrip('.').lower()}" for e in extensions if e.strip()} + if not suffixes: + suffixes = {".robot"} + suffixes.add(".resource") + return suffixes + + +def _get_fast_discovery_tag_patterns( + cmd_options: List[str], robot_options_and_args: Tuple[str, ...] +) -> Tuple[Optional[TagPatterns], Optional[TagPatterns]]: + all_options = tuple([*cmd_options, *robot_options_and_args]) + include_tags = _get_robot_option_values(all_options, "--include", "-i") + exclude_tags = _get_robot_option_values(all_options, "--exclude", "-e") + include_patterns = TagPatterns(include_tags) if include_tags else None + exclude_patterns = TagPatterns(exclude_tags) if exclude_tags else None + return include_patterns, exclude_patterns + + +def _fast_match_tags( + tags: Iterable[str], include_patterns: Optional[TagPatterns], exclude_patterns: Optional[TagPatterns] +) -> bool: + if include_patterns and not include_patterns.match(tags): + return False + if exclude_patterns and exclude_patterns.match(tags): + return False + return True + + +def _resolve_fast_candidate_path(candidate: str, root_folder: Optional[Path]) -> Path: + candidate_path = Path(candidate) + if not candidate_path.is_absolute(): + candidate_path = (root_folder or Path.cwd()) / candidate_path + return normalized_path(candidate_path) + + +def _iter_fast_discovery_files( + app: Application, + root_folder: Optional[Path], + profile: Any, + candidates: Optional[List[str]], + allowed_suffixes: Set[str], +) -> List[Path]: + if candidates: + result = [] + for candidate in candidates: + p = _resolve_fast_candidate_path(candidate, root_folder) + if p.suffix.lower() in allowed_suffixes: + result.append(p) + return sorted(set(result)) + + search_paths = set( + ( + [*(app.config.default_paths if app.config.default_paths else ())] + if profile.paths is None + else profile.paths + if isinstance(profile.paths, list) + else [profile.paths] + ) + ) + if not search_paths: + search_paths = {"."} + + return sorted( + set( + p + for p in iter_files( + (Path(s) for s in search_paths), + root=root_folder, + ignore_files=[ROBOT_IGNORE_FILE, GIT_IGNORE_FILE], + include_hidden=False, + verbose_callback=app.verbose, + ) + if p.suffix.lower() in allowed_suffixes + ) + ) + + +def _extract_force_tags_from_path(path: Path) -> List[str]: + result: List[str] = [] + current_section: Optional[str] = None + token_source = _get_token_source_for_path(path) + + for statement_tokens in _iter_statements(token_source): + first = statement_tokens[0] + if first.type == Token.SETTING_HEADER: + current_section = Token.SETTING_HEADER + continue + if first.type in Token.HEADER_TOKENS: + current_section = first.type + continue + if current_section != Token.SETTING_HEADER: + continue + + if first.type == Token.FORCE_TAGS: + result.extend(str(t).strip() for t in statement_tokens[1:] if str(t).strip()) + + return list(dict.fromkeys(result)) + + +def _get_token_source_for_path(path: Path) -> Union[str, Path]: + if _stdin_data is not None: + uri = str(Uri.from_path(path)) + stdin_text = _stdin_data.get(Uri(uri).normalized()) + if stdin_text is not None: + return stdin_text + return str(path) + + +def _iter_statements(token_source: Union[str, Path]) -> Iterable[List[Token]]: + statement: List[Token] = [] + + try: + tokens = get_tokens(token_source) + except (OSError, UnicodeDecodeError): + return + + for token in tokens: + if token.type == Token.EOS: + if statement: + yield statement + statement = [] + continue + if token.type in Token.NON_DATA_TOKENS: + continue + + statement.append(token) + + if statement: + yield statement + + +def _get_cached_force_tags_for_file( + path: Path, + force_tags_cache: Dict[Path, List[str]], +) -> List[str]: + if path not in force_tags_cache: + force_tags_cache[path] = _extract_force_tags_from_path(path) + return force_tags_cache[path] + + +def _get_cached_inherited_force_tags( + file_path: Path, + workspace_path: Path, + force_tags_cache: Dict[Path, List[str]], + inherited_cache: Dict[Path, List[str]], +) -> List[str]: + if file_path in inherited_cache: + return inherited_cache[file_path] + + aggregated: List[str] = [] + + current_dir = file_path.parent + while True: + init_file = current_dir / "__init__.robot" + if init_file != file_path and init_file.is_file(): + aggregated.extend(_get_cached_force_tags_for_file(init_file, force_tags_cache)) + + if current_dir == workspace_path or current_dir.parent == current_dir: + break + current_dir = current_dir.parent + + aggregated.extend(_get_cached_force_tags_for_file(file_path, force_tags_cache)) + inherited_cache[file_path] = list(dict.fromkeys(aggregated)) + return inherited_cache[file_path] + + +def _extract_fast_items_from_path(path: Path) -> List[Tuple[str, str, int, List[str]]]: + result: List[Tuple[str, str, int, List[str]]] = [] + current_section: Optional[str] = None + task_header = getattr(Token, "TASK_HEADER", None) + token_source = _get_token_source_for_path(path) + current_item_index: Optional[int] = None + collect_tag_continuation = False + + for row_tokens in _iter_statements(token_source): + first = row_tokens[0] + if first.type == Token.TESTCASE_HEADER: + current_section = "test" + current_item_index = None + collect_tag_continuation = False + continue + if task_header is not None and first.type == task_header: + current_section = "task" + current_item_index = None + collect_tag_continuation = False + continue + if first.type in Token.HEADER_TOKENS: + current_section = None + current_item_index = None + collect_tag_continuation = False + continue + if current_section is None: + continue + + if first.type == Token.TESTCASE_NAME: + name = str(first).strip() + if name: + result.append((current_section, name, first.lineno, [])) + current_item_index = len(result) - 1 + collect_tag_continuation = False + continue + + if current_item_index is None: + continue + + _, _, _, current_tags = result[current_item_index] + + if first.type == Token.TAGS: + current_tags.extend(str(t).strip() for t in row_tokens[1:] if str(t).strip()) + collect_tag_continuation = True + continue + + if collect_tag_continuation and first.type == Token.ARGUMENT: + current_tags.extend(str(t).strip() for t in row_tokens if str(t).strip()) + continue + + collect_tag_continuation = False + + for i, (item_type, name, lineno, tags) in enumerate(result): + if tags: + result[i] = (item_type, name, lineno, list(dict.fromkeys(tags))) + + return result + + +def _build_fast_discovery_result( + app: Application, + by_longname: Tuple[str, ...], + exclude_by_longname: Tuple[str, ...], + robot_options_and_args: Tuple[str, ...], +) -> ResultItem: + root_folder, profile, cmd_options = handle_robot_options(app, robot_options_and_args) + + if unsupported_option := _has_fast_discovery_unsupported_options(cmd_options, robot_options_and_args): + raise click.ClickException( + f"Fast discovery does not support option '{unsupported_option}'. Use 'discover all' instead." + ) + + suite_filters = _get_robot_option_values(robot_options_and_args, "--suite") + test_filters = _get_robot_option_values(robot_options_and_args, "--test") + allowed_suffixes = _get_fast_discovery_suffixes(cmd_options, robot_options_and_args) + include_tag_patterns, exclude_tag_patterns = _get_fast_discovery_tag_patterns(cmd_options, robot_options_and_args) + + workspace_path = Path.cwd() + workspace_item = TestItem( + type="workspace", + id=str(workspace_path), + name=workspace_path.name, + longname=workspace_path.name, + uri=str(Uri.from_path(workspace_path)), + source=str(workspace_path), + rel_source=get_rel_source(workspace_path), + needs_parse_include=get_robot_version() >= (6, 1), + children=[], + ) + + suite_by_id: Dict[str, TestItem] = {workspace_item.id: workspace_item} + files = _iter_fast_discovery_files(app, root_folder, profile, _stdin_candidates, allowed_suffixes) + force_tags_cache: Dict[Path, List[str]] = {} + inherited_force_tags_cache: Dict[Path, List[str]] = {} + tests_count = 0 + tasks_count = 0 + + for file_path in files: + rel_parts = file_path.parts + try: + rel_parts = file_path.relative_to(workspace_path).parts + except ValueError: + pass + + parent = workspace_item + current_dir = workspace_path + for part in rel_parts[:-1]: + current_dir = current_dir / part + suite_name = TestSuite.name_from_source(current_dir) + suite_longname = f"{parent.longname}.{suite_name}" + suite_id = f"{current_dir};{suite_longname}" + suite_item = suite_by_id.get(suite_id) + if suite_item is None: + suite_item = TestItem( + type="suite", + id=suite_id, + name=suite_name, + longname=suite_longname, + uri=str(Uri.from_path(current_dir)), + source=str(current_dir), + rel_source=get_rel_source(current_dir), + needs_parse_include=get_robot_version() >= (6, 1), + children=[], + rpa=False, + ) + parent.children = parent.children or [] + parent.children.append(suite_item) + suite_by_id[suite_id] = suite_item + parent = suite_item + + if file_path.name.lower() == "__init__.robot": + target_suite = parent + else: + suite_name = TestSuite.name_from_source(file_path) + suite_longname = f"{parent.longname}.{suite_name}" + suite_id = f"{file_path};{suite_longname}" + suite_item = suite_by_id.get(suite_id) + if suite_item is None: + suite_item = TestItem( + type="suite", + id=suite_id, + name=suite_name, + longname=suite_longname, + uri=str(Uri.from_path(file_path)), + source=str(file_path), + rel_source=get_rel_source(file_path), + range=Range(start=Position(line=0, character=0), end=Position(line=0, character=0)), + needs_parse_include=get_robot_version() >= (6, 1), + children=[], + rpa=False, + ) + parent.children = parent.children or [] + parent.children.append(suite_item) + suite_by_id[suite_id] = suite_item + target_suite = suite_item + + if suite_filters and not any(_fast_match(target_suite.longname, f) for f in suite_filters): + continue + if by_longname and not any(_fast_match(target_suite.longname, f) for f in by_longname): + continue + if exclude_by_longname and any(_fast_match(target_suite.longname, f) for f in exclude_by_longname): + continue + + inherited_force_tags = _get_cached_inherited_force_tags( + file_path, workspace_path, force_tags_cache, inherited_force_tags_cache + ) + for item_type, test_name, lineno, test_tags in _extract_fast_items_from_path(file_path): + combined_tags = list(dict.fromkeys([*inherited_force_tags, *test_tags])) + if not _fast_match_tags(combined_tags, include_tag_patterns, exclude_tag_patterns): + continue + + longname = f"{target_suite.longname}.{test_name}" + if test_filters and not any(_fast_match(longname, f) for f in test_filters): + continue + if by_longname and not any(_fast_match(longname, f) for f in by_longname): + continue + if exclude_by_longname and any(_fast_match(longname, f) for f in exclude_by_longname): + continue + + if item_type == "task": + target_suite.rpa = True + tasks_count += 1 + else: + tests_count += 1 + + child = TestItem( + type=item_type, + id=f"{file_path};{longname};{lineno}", + name=test_name, + longname=longname, + lineno=lineno, + uri=str(Uri.from_path(file_path)), + source=str(file_path), + rel_source=get_rel_source(file_path), + range=Range( + start=Position(line=lineno - 1, character=0), + end=Position(line=lineno - 1, character=0), + ), + tags=combined_tags if combined_tags else None, + rpa=item_type == "task", + ) + target_suite.children = target_suite.children or [] + target_suite.children.append(child) + + app.verbose( + lambda: ( + "discover fast summary: " + f"files={len(files)} tests={tests_count} tasks={tasks_count} " + f"candidates={len(_stdin_candidates or [])}" + ) + ) + return ResultItem([workspace_item], diagnostics=None) + + def get_rel_source(source: Union[str, Path, None]) -> Optional[str]: if source is None: return None @@ -267,6 +696,11 @@ def __init__(self) -> None: self._collected: List[MutableMapping[str, Any]] = [NormalizedDict(ignore="_")] def visit_suite(self, suite: TestSuite) -> None: + if _discover_log_visited_files and _app is not None and suite.source is not None: + source_path = Path(suite.source) + if source_path.is_file(): + _app.verbose(lambda: f"discover: visit file {source_path}") + if suite.name in self._collected[-1] and suite.parent.source: LOGGER.warn( ( @@ -402,12 +836,42 @@ def discover(app: Application, show_diagnostics: bool, read_from_stdin: bool) -> global _app _app = app app.show_diagnostics = show_diagnostics or app.config.log_enabled + global _stdin_data + global _stdin_candidates + global _discover_log_visited_files + _stdin_data = None + _stdin_candidates = None + _discover_log_visited_files = os.getenv("ROBOTCODE_DISCOVER_LOG_VISITED_FILES", "").lower() in [ + "on", + "1", + "yes", + "true", + ] if read_from_stdin: - global _stdin_data - _stdin_data = { - Uri(k).normalized(): v for k, v in from_json(sys.stdin.buffer.read(), Dict[str, str], strict=True).items() - } - app.verbose(f"Read data from stdin: {_stdin_data!r}") + stdin_raw = json.loads(sys.stdin.buffer.read().decode("utf-8")) + + if isinstance(stdin_raw, dict) and ("documents" in stdin_raw or "candidates" in stdin_raw): + documents_raw = stdin_raw.get("documents", {}) + candidates_raw = stdin_raw.get("candidates", []) + _stdin_data = ( + {Uri(k).normalized(): v for k, v in documents_raw.items() if isinstance(k, str) and isinstance(v, str)} + if isinstance(documents_raw, dict) + else {} + ) + _stdin_candidates = ( + [v for v in candidates_raw if isinstance(v, str)] if isinstance(candidates_raw, list) else None + ) + else: + _stdin_data = ( + {Uri(k).normalized(): v for k, v in stdin_raw.items() if isinstance(k, str) and isinstance(v, str)} + if isinstance(stdin_raw, dict) + else {} + ) + _stdin_candidates = None + + app.verbose( + f"Read data from stdin: documents={len(_stdin_data)} candidates={len(_stdin_candidates or [])}" + ) RE_IN_FILE_LINE_MATCHER = re.compile( @@ -478,12 +942,18 @@ def handle_options( exclude_by_longname: Tuple[str, ...], robot_options_and_args: Tuple[str, ...], ) -> Tuple[TestSuite, Collector, Optional[Dict[str, List[Diagnostic]]]]: - root_folder, profile, cmd_options = handle_robot_options(app, robot_options_and_args) + started = time.perf_counter() + robot_options_and_args_with_candidates = ( + (*robot_options_and_args, *_stdin_candidates) if _stdin_candidates else robot_options_and_args + ) + root_folder, profile, cmd_options = handle_robot_options(app, robot_options_and_args_with_candidates) + after_handle_robot_options = time.perf_counter() with app.chdir(root_folder) as orig_folder: diagnostics_logger = DiagnosticsLogger() try: _patch() + after_patch = time.perf_counter() options, arguments = RobotFrameworkEx( app, @@ -499,7 +969,8 @@ def handle_options( orig_folder, by_longname, exclude_by_longname, - ).parse_arguments((*cmd_options, "--runemptysuite", *robot_options_and_args)) + ).parse_arguments((*cmd_options, "--runemptysuite", *robot_options_and_args_with_candidates)) + after_parse_arguments = time.perf_counter() settings = RobotSettings(options) @@ -509,6 +980,7 @@ def handle_options( LOGGER.unregister_console_logger() LOGGER.register_logger(diagnostics_logger) + after_logger_setup = time.perf_counter() if get_robot_version() >= (5, 0): if settings.pythonpath: @@ -540,16 +1012,43 @@ def handle_options( ) suite = builder.build(*arguments) + after_build = time.perf_counter() settings.rpa = suite.rpa if settings.pre_run_modifiers: suite.visit(ModelModifier(settings.pre_run_modifiers, settings.run_empty_suite, LOGGER)) + after_modifiers = time.perf_counter() suite.configure(**settings.suite_config) + after_configure = time.perf_counter() collector = Collector() suite.visit(collector) + after_collect = time.perf_counter() + diagnostics = build_diagnostics(diagnostics_logger.messages) + after_diagnostics = time.perf_counter() + + app.verbose( + lambda: ( + "discover timings (s): " + f"config/profile={after_handle_robot_options - started:.3f}, " + f"patch={after_patch - after_handle_robot_options:.3f}, " + f"parse_args={after_parse_arguments - after_patch:.3f}, " + f"logger_setup={after_logger_setup - after_parse_arguments:.3f}, " + f"builder_build={after_build - after_logger_setup:.3f}, " + f"pre_run_modifiers={after_modifiers - after_build:.3f}, " + f"suite_configure={after_configure - after_modifiers:.3f}, " + f"collector_visit={after_collect - after_configure:.3f}, " + f"diagnostics={after_diagnostics - after_collect:.3f}, " + f"total={after_diagnostics - started:.3f}, " + f"arguments={len(arguments)}, " + f"candidates={len(_stdin_candidates or [])}, " + f"tests={collector.statistics.tests}, " + f"tasks={collector.statistics.tasks}, " + f"suites={collector.statistics.suites}" + ) + ) - return suite, collector, build_diagnostics(diagnostics_logger.messages) + return suite, collector, diagnostics except Information as err: app.echo(str(err)) @@ -583,6 +1082,22 @@ def print() -> Iterable[str]: app.echo_via_pager(print()) +def print_machine_data(app: Application, data: Any) -> None: + if app.config.output_format in (OutputFormat.JSON, OutputFormat.JSON_INDENT): + text = as_json( + data, + indent=app.config.output_format == OutputFormat.JSON_INDENT, + compact=app.config.output_format == OutputFormat.JSON, + ) + sys.stdout.write(text) + if not text.endswith(os.linesep): + sys.stdout.write(os.linesep) + sys.stdout.flush() + return + + app.print_data(data, remove_defaults=True) + + @discover.command( context_settings={"allow_extra_args": True, "ignore_unknown_options": True}, add_help_option=True, @@ -655,7 +1170,7 @@ def print(item: TestItem, indent: int = 0) -> Iterable[str]: print_statistics(app, suite, collector) else: - app.print_data(ResultItem([collector.all], diagnostics), remove_defaults=True) + print_machine_data(app, ResultItem([collector.all], diagnostics)) def _test_or_tasks( @@ -692,7 +1207,7 @@ def print(items: List[TestItem]) -> Iterable[str]: print_statistics(app, suite, collector) else: - app.print_data(ResultItem(collector.test_and_tasks, diagnostics), remove_defaults=True) + print_machine_data(app, ResultItem(collector.test_and_tasks, diagnostics)) @discover.command( @@ -844,7 +1359,7 @@ def print(items: List[TestItem]) -> Iterable[str]: print_statistics(app, suite, collector) else: - app.print_data(ResultItem(collector.suites, diagnostics), remove_defaults=True) + print_machine_data(app, ResultItem(collector.suites, diagnostics)) @dataclass @@ -944,7 +1459,7 @@ def print(tags: Dict[str, List[TestItem]]) -> Iterable[str]: print_statistics(app, suite, collector) else: - app.print_data(TagsResult(collector.normalized_tags), remove_defaults=True) + print_machine_data(app, TagsResult(collector.normalized_tags)) @dataclass @@ -1013,7 +1528,7 @@ def info(app: Application) -> None: for key, value in as_dict(info, remove_defaults=True).items(): app.echo_via_pager(f"{key}: {value}") else: - app.print_data(info, remove_defaults=True) + print_machine_data(app, info) @discover.command(add_help_option=True) @@ -1090,4 +1605,30 @@ def print() -> Iterable[str]: app.echo_via_pager(print()) else: + print_machine_data(app, result) + + +@discover.command( + context_settings={"allow_extra_args": True, "ignore_unknown_options": True}, + add_help_option=True, + epilog="Use `-- --help` to see `robot` help.", +) +@add_options(*ROBOT_OPTIONS) +@pass_application +def fast( + app: Application, + by_longname: Tuple[str, ...], + exclude_by_longname: Tuple[str, ...], + robot_options_and_args: Tuple[str, ...], +) -> None: + """\ + Fast test discovery using lexical scanning only. + + This mode is optimized for speed and intentionally does not support all + Robot Framework discovery semantics. + """ + result = _build_fast_discovery_result(app, by_longname, exclude_by_longname, robot_options_and_args) + if app.config.output_format is None or app.config.output_format == OutputFormat.TEXT: app.print_data(result, remove_defaults=True) + else: + print_machine_data(app, result) diff --git a/vscode-client/extension/pythonmanger.ts b/vscode-client/extension/pythonmanger.ts index 869d767b1..ce5f1cf3b 100644 --- a/vscode-client/extension/pythonmanger.ts +++ b/vscode-client/extension/pythonmanger.ts @@ -189,7 +189,9 @@ export class PythonManager { noPager, ); - this.outputChannel.appendLine(`executeRobotCode: ${pythonCommand} ${final_args.join(" ")}`); + this.outputChannel.appendLine(`executeRobotCode: cwd=${folder.uri.fsPath}`); + this.outputChannel.appendLine(`executeRobotCode: command=${pythonCommand}`); + this.outputChannel.appendLine(`executeRobotCode: args=${JSON.stringify(final_args)}`); return new Promise((resolve, reject) => { const abortController = new AbortController(); @@ -235,8 +237,13 @@ export class PythonManager { this.outputChannel.appendLine(`executeRobotCode: exit code ${code ?? "null"}`); if (code === 0) { try { - resolve(JSON.parse(stdout)); + resolve(this.parseJsonOutput(stdout)); } catch (err) { + const head = stdout.slice(0, 1000); + const tail = stdout.slice(-1000); + this.outputChannel.appendLine( + `executeRobotCode: invalid json output length=${stdout.length} head:\n${head}\n...tail:\n${tail}`, + ); reject(err); } } else { @@ -248,6 +255,95 @@ export class PythonManager { }); } + // eslint-disable-next-line class-methods-use-this + private parseJsonOutput(stdout: string): unknown { + const text = stdout + .replace(/\u001B\[[0-?]*[ -/]*[@-~]/g, "") + .replace(/\u001B\][^\u0007]*(?:\u0007|\u001B\\)/g, "") + .replace(/\u0000/g, "") + .trim(); + if (!text) { + throw new Error("Executing robotcode failed: empty json output."); + } + + try { + return JSON.parse(text); + } catch { + const starts: number[] = []; + let bestParsedValue: unknown | undefined; + let bestParsedLength = -1; + for (let i = 0; i < text.length; i += 1) { + if (text[i] === "{" || text[i] === "[") { + starts.push(i); + } + } + + for (const start of starts) { + const stack: string[] = []; + let inString = false; + let escaped = false; + let parsedValue: unknown | undefined; + for (let i = start; i < text.length; i += 1) { + const ch = text[i]; + if (inString) { + if (escaped) { + escaped = false; + } else if (ch === "\\") { + escaped = true; + } else if (ch === '"') { + inString = false; + } + continue; + } + + if (ch === '"') { + inString = true; + continue; + } + if (ch === "{") { + stack.push("}"); + continue; + } + if (ch === "[") { + stack.push("]"); + continue; + } + if (ch === "}" || ch === "]") { + if (stack.length === 0) break; + const expected = stack.pop(); + if (expected !== ch) break; + if (stack.length === 0) { + const candidate = text.slice(start, i + 1); + try { + parsedValue = JSON.parse(candidate); + } catch { + parsedValue = undefined; + } + if (parsedValue !== undefined) { + const remaining = text.slice(i + 1).trim(); + if (remaining.length === 0) { + return parsedValue; + } + if (candidate.length > bestParsedLength) { + bestParsedValue = parsedValue; + bestParsedLength = candidate.length; + } + } + continue; + } + } + } + } + + if (bestParsedValue !== undefined) { + return bestParsedValue; + } + + const sample = text.slice(0, 500); + throw new Error(`Executing robotcode failed: output did not contain parseable JSON. Sample: ${sample}`); + } + } + public async buildRobotCodeCommand( folder: vscode.WorkspaceFolder, args: string[], diff --git a/vscode-client/extension/testcontrollermanager.ts b/vscode-client/extension/testcontrollermanager.ts index f6bb77b57..279cfc947 100644 --- a/vscode-client/extension/testcontrollermanager.ts +++ b/vscode-client/extension/testcontrollermanager.ts @@ -1,7 +1,9 @@ import { red, yellow, blue } from "ansi-colors"; +import { spawn } from "child_process"; import * as vscode from "vscode"; import { DebugManager } from "./debugmanager"; import * as fs from "fs"; +import * as path from "path"; import { ClientState, LanguageClientsManager, toVsCodeRange } from "./languageclientsmanger"; import { escapeRobotGlobPatterns, filterAsync, Mutex, sleep, truncateAndReplaceNewlines, WeakValueMap } from "./utils"; @@ -53,6 +55,8 @@ interface RobotCodeDiscoverResult { diagnostics?: { [Key: string]: Diagnostic[] }; } +type FastDiscoveryPrefilterCommand = "auto" | "gitGrep" | "grep" | "none"; + interface RobotCodeProfileInfo { name: string; description: string; @@ -719,6 +723,10 @@ export class TestControllerManager { if (!(await this.languageClientsManager.isValidRobotEnvironmentInFolder(folder))) { return {}; } + const startTime = Date.now(); + this.outputChannel.appendLine( + `discover tests: start workspace=${folder.name} discoverArgs=${discoverArgs.join(" ")} extraArgsCount=${extraArgs.length}`, + ); const config = vscode.workspace.getConfiguration(CONFIG_SECTION, folder); const profiles = config.get("profiles", []); @@ -748,7 +756,7 @@ export class TestControllerManager { ...pythonPath.flatMap((v) => ["-P", v]), ...languages.flatMap((v) => ["--language", v]), ...robotArgs, - ...extraArgs, + ...(extraArgs.length ? ["--", ...extraArgs] : []), ], profiles, "json", @@ -787,11 +795,245 @@ export class TestControllerManager { }); } + this.outputChannel.appendLine( + `discover tests: done workspace=${folder.name} elapsedMs=${Date.now() - startTime} items=${result?.items?.length ?? 0}`, + ); + return result; } private readonly lastDiscoverResults = new WeakMap(); + // eslint-disable-next-line class-methods-use-this + private isFastDiscoveryEnabled(folder: vscode.WorkspaceFolder): boolean { + return vscode.workspace + .getConfiguration(CONFIG_SECTION, folder) + .get("testExplorer.fastDiscovery.enabled", false); + } + + // eslint-disable-next-line class-methods-use-this + private getFastDiscoveryPrefilterCommand(folder: vscode.WorkspaceFolder): FastDiscoveryPrefilterCommand { + return vscode.workspace + .getConfiguration(CONFIG_SECTION, folder) + .get("testExplorer.fastDiscovery.prefilterCommand", "auto"); + } + + // eslint-disable-next-line class-methods-use-this + private getFastDiscoveryRoots(folder: vscode.WorkspaceFolder): string[] { + const configuredPaths = vscode.workspace + .getConfiguration(CONFIG_SECTION, folder) + .get("robot.paths"); + const roots = configuredPaths?.length ? configuredPaths : ["."]; + return roots.map((v) => v.trim()).filter((v) => v.length > 0); + } + + // eslint-disable-next-line class-methods-use-this + private isFastDiscoveryCommandEnabled(folder: vscode.WorkspaceFolder): boolean { + return vscode.workspace + .getConfiguration(CONFIG_SECTION, folder) + .get("testExplorer.fastDiscovery.command.enabled", false); + } + + // eslint-disable-next-line class-methods-use-this + private async runShellCommand( + command: string, + args: string[], + cwd: string, + token?: vscode.CancellationToken, + ): Promise<{ exitCode: number | null; stdout: string; stderr: string; error?: unknown }> { + return await new Promise((resolve) => { + const abortController = new AbortController(); + + token?.onCancellationRequested(() => { + abortController.abort(); + }); + + const process = spawn(command, args, { + cwd, + signal: abortController.signal, + }); + + let stdout = ""; + let stderr = ""; + + process.stdout.setEncoding("utf8"); + process.stderr.setEncoding("utf8"); + process.stdout.on("data", (data) => { + stdout += data; + }); + process.stderr.on("data", (data) => { + stderr += data; + }); + + process.on("error", (error) => { + resolve({ exitCode: null, stdout, stderr, error }); + }); + + process.on("exit", (code) => { + resolve({ exitCode: code, stdout, stderr }); + }); + }); + } + + // eslint-disable-next-line class-methods-use-this + private toDiscoverPathArg(folder: vscode.WorkspaceFolder, filePath: string): string { + const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(folder.uri.fsPath, filePath); + const relativePath = path.relative(folder.uri.fsPath, absolutePath); + if (relativePath && !relativePath.startsWith("..") && !path.isAbsolute(relativePath)) { + return relativePath; + } + + return absolutePath; + } + + // eslint-disable-next-line class-methods-use-this + private parsePrefilterFileList(stdout: string): string[] { + if (!stdout) return []; + if (stdout.includes("\0")) { + return stdout + .split("\0") + .map((v) => v.trim()) + .filter((v) => v.length > 0); + } + return stdout + .split(/\r?\n/) + .map((v) => v.trim()) + .filter((v) => v.length > 0); + } + + // eslint-disable-next-line class-methods-use-this + private normalizeRootForSearch(folder: vscode.WorkspaceFolder, root: string): string | undefined { + const normalizedRoot = root.trim(); + if (!normalizedRoot) return undefined; + if (!path.isAbsolute(normalizedRoot)) return normalizedRoot; + + const relativeRoot = path.relative(folder.uri.fsPath, normalizedRoot); + if (!relativeRoot || relativeRoot.startsWith("..")) return undefined; + return relativeRoot; + } + + private async prefilterWithGitGrep( + folder: vscode.WorkspaceFolder, + roots: string[], + token?: vscode.CancellationToken, + ): Promise { + const fileExtensions = this.languageClientsManager.fileExtensions; + const normalizedRoots = roots + .map((v) => this.normalizeRootForSearch(folder, v)) + .filter((v) => v !== undefined) + .map((v) => v.replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/+$/, "")); + if (normalizedRoots.length === 0) return []; + const pathSpecs = normalizedRoots.flatMap((root) => + fileExtensions.map((ext) => `:(glob)${root.length > 0 && root !== "." ? `${root}/` : ""}**/*.${ext}`), + ); + + const result = await this.runShellCommand( + "git", + ["grep", "-z", "-l", "-E", "^\\*\\*\\*\\s*(Test Cases|Tasks)\\s*\\*\\*\\*\\s*$", "--", ...pathSpecs], + folder.uri.fsPath, + token, + ); + + if (result.error !== undefined) { + this.outputChannel.appendLine( + `fast discovery: git grep unavailable (${result.error?.toString() ?? "unknown error"})`, + ); + return undefined; + } + + if (result.exitCode !== 0 && result.exitCode !== 1) { + this.outputChannel.appendLine( + `fast discovery: git grep failed with exit code ${result.exitCode?.toString() ?? "null"}: ${result.stderr}`, + ); + return undefined; + } + + return this.parsePrefilterFileList(result.stdout).map((v) => this.toDiscoverPathArg(folder, v)); + } + + private async prefilterWithGrep( + folder: vscode.WorkspaceFolder, + roots: string[], + token?: vscode.CancellationToken, + ): Promise { + const normalizedRoots = roots.map((v) => this.normalizeRootForSearch(folder, v)).filter((v) => v !== undefined); + if (normalizedRoots.length === 0) return []; + + const includeArgs = this.languageClientsManager.fileExtensions.map((ext) => `--include=*.${ext}`); + const result = await this.runShellCommand( + "grep", + ["-RIlE", "-Z", "^\\*\\*\\*\\s*(Test Cases|Tasks)\\s*\\*\\*\\*\\s*$", ...includeArgs, ...normalizedRoots], + folder.uri.fsPath, + token, + ); + + if (result.error !== undefined) { + this.outputChannel.appendLine( + `fast discovery: grep unavailable (${result.error?.toString() ?? "unknown error"})`, + ); + return undefined; + } + + if (result.exitCode !== 0 && result.exitCode !== 1) { + this.outputChannel.appendLine( + `fast discovery: grep failed with exit code ${result.exitCode?.toString() ?? "null"}: ${result.stderr}`, + ); + return undefined; + } + + return this.parsePrefilterFileList(result.stdout).map((v) => this.toDiscoverPathArg(folder, v)); + } + + private async getFastDiscoveryCandidates( + folder: vscode.WorkspaceFolder, + token?: vscode.CancellationToken, + ): Promise { + if (!this.isFastDiscoveryEnabled(folder)) return undefined; + + const roots = this.getFastDiscoveryRoots(folder); + const prefilterMode = this.getFastDiscoveryPrefilterCommand(folder); + this.outputChannel.appendLine( + `fast discovery: start workspace=${folder.name} mode=${prefilterMode} roots=${roots.join(",")}`, + ); + + const runGit = async () => await this.prefilterWithGitGrep(folder, roots, token); + const runGrep = async () => await this.prefilterWithGrep(folder, roots, token); + + let files: string[] | undefined; + + switch (prefilterMode) { + case "gitGrep": + files = await runGit(); + break; + case "grep": + files = await runGrep(); + break; + case "none": + return undefined; + case "auto": + default: + files = (await runGit()) ?? (await runGrep()); + break; + } + + if (files === undefined) return undefined; + + this.outputChannel.appendLine( + `fast discovery: prefilter mode=${prefilterMode} candidates=${files.length} in workspace ${folder.name}`, + ); + return files; + } + + // eslint-disable-next-line class-methods-use-this + private isLegacyDiscoverStdinFormatError(error: unknown): boolean { + if (!(error instanceof Error)) return false; + const message = error.message ?? ""; + return ( + message.includes('Invalid value for "documents"') || + message.includes("must be of type `` but is `dict`") + ); + } + public async getTestsFromWorkspaceFolder( folder: vscode.WorkspaceFolder, token?: vscode.CancellationToken, @@ -819,18 +1061,86 @@ export class TestControllerManager { } } - const result = await this.discoverTests( - folder, - ["discover", "--read-from-stdin", "all"], - [], - JSON.stringify(o), - true, - token, - ); + const fastDiscoveryCandidates = await this.getFastDiscoveryCandidates(folder, token); + if (token?.isCancellationRequested) return undefined; - this.lastDiscoverResults.set(folder, result); + const useFastDiscoveryCommand = + fastDiscoveryCandidates !== undefined && this.isFastDiscoveryCommandEnabled(folder); + const initialDiscoverSubCommand = useFastDiscoveryCommand ? "fast" : "all"; - return result?.items; + if (fastDiscoveryCandidates !== undefined && fastDiscoveryCandidates.length === 0) { + this.lastDiscoverResults.set(folder, { items: [] }); + return []; + } + + const fullStdioData = JSON.stringify(o); + const prefilteredStdioData = + fastDiscoveryCandidates !== undefined + ? JSON.stringify({ documents: o, candidates: fastDiscoveryCandidates }) + : fullStdioData; + + const runWorkspaceDiscover = async ( + subCommand: "all" | "fast", + extraArgs: string[], + stdioData: string, + ): Promise => + await this.discoverTests( + folder, + ["discover", "--read-from-stdin", subCommand], + extraArgs, + stdioData, + true, + token, + ); + + const runWorkspaceDiscoverWithLegacyRetry = async ( + subCommand: "all" | "fast", + ): Promise => { + try { + return await runWorkspaceDiscover(subCommand, [], prefilteredStdioData); + } catch (error) { + if (fastDiscoveryCandidates !== undefined && this.isLegacyDiscoverStdinFormatError(error)) { + this.outputChannel.appendLine( + "fast discovery: stdin candidates payload not supported by bundled runner, retrying with legacy argv candidates", + ); + return await runWorkspaceDiscover(subCommand, fastDiscoveryCandidates, fullStdioData); + } + throw error; + } + }; + + let result: RobotCodeDiscoverResult; + try { + result = await runWorkspaceDiscoverWithLegacyRetry(initialDiscoverSubCommand); + } catch (error) { + if (useFastDiscoveryCommand) { + this.outputChannel.appendLine( + `fast discovery: discover ${initialDiscoverSubCommand} failed, retrying discover all (${(error as Error).message ?? String(error)})`, + ); + result = await runWorkspaceDiscoverWithLegacyRetry("all"); + } else { + throw error; + } + } + + let finalResult = result; + if (fastDiscoveryCandidates !== undefined && (result?.items?.length ?? 0) === 0) { + this.outputChannel.appendLine( + `fast discovery: empty result for workspace=${folder.name}, rerunning full discovery without prefilter candidates`, + ); + finalResult = await this.discoverTests( + folder, + ["discover", "--read-from-stdin", "all"], + [], + fullStdioData, + true, + token, + ); + } + + this.lastDiscoverResults.set(folder, finalResult); + + return finalResult?.items; } catch (e) { if (e instanceof Error) { if (e.name === "AbortError") {