# -*- coding: utf-8 -*- """ ripgrep (rg.exe) 封装:日志 stdin 过滤、目录内文件搜索。 """ from __future__ import annotations import json import os import re import shutil import subprocess import sys from pathlib import Path from typing import List, Optional, Tuple _RG_NAMES = ("rg.exe", "rg") def find_rg_executable() -> Optional[Path]: """解析 rg 可执行文件;找不到返回 None。""" env = (os.environ.get("RG_PATH") or "").strip() if env: p = Path(env) if p.is_file(): return p if getattr(sys, "frozen", False): base = Path(sys.executable).resolve().parent for name in _RG_NAMES: cand = base / name if cand.is_file(): return cand # resources/bin/rg.exe(开发目录) here = Path(__file__).resolve().parent.parent project_root = here.parent for name in _RG_NAMES: cand = project_root / "resources" / "bin" / name if cand.is_file(): return cand w = shutil.which("rg") if w: return Path(w) return None def _build_rg_regex_pattern(pattern: str, whole_word: bool) -> str: if whole_word: return rf"\b(?:{pattern})\b" return pattern def _utf8_byte_range_to_qt_positions(s: str, start_byte: int, end_byte: int) -> Tuple[int, int]: """ 将 UTF-8 字节区间 [start_byte, end_byte) 转为 QTextCursor 使用的 UTF-16 偏移(与 Qt 一致)。 """ b = s.encode("utf-8") if start_byte < 0 or end_byte > len(b) or start_byte > end_byte: raise ValueError("invalid utf-8 byte range for document") prefix_start = b[:start_byte].decode("utf-8") prefix_end = b[:end_byte].decode("utf-8") qt_start = len(prefix_start.encode("utf-16-le")) // 2 qt_end = len(prefix_end.encode("utf-16-le")) // 2 return qt_start, qt_end def rg_match_spans_qt( full_text: str, pattern: str, case_sensitive: bool, use_regex: bool, whole_word: bool, max_spans: int = 3000, ) -> Tuple[Optional[List[Tuple[int, int]]], Optional[str]]: """ 使用 ``rg --json`` 得到与 ripgrep 完全一致的匹配区间,并转为 Qt 文档坐标(UTF-16)。 返回 (spans, error)。error 非空表示未调用 rg 或失败;spans 为 ``(start, end)`` 列表,按从左到右顺序。 """ exe = find_rg_executable() if not exe: return None, "未找到 rg.exe" q = (pattern or "").strip() if not q: return [], None args: List[str] = [str(exe), "--json", "--color", "never"] if not case_sensitive: args.append("-i") if not use_regex: args.append("--fixed-strings") if whole_word: args.append("-w") pat_arg = q else: pat_arg = _build_rg_regex_pattern(q, whole_word) args.extend(["--", pat_arg, "-"]) try: r = subprocess.run( args, input=full_text.encode("utf-8"), capture_output=True, timeout=120, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0) if sys.platform == "win32" else 0, ) except Exception as e: return None, str(e) err_txt = (r.stderr or b"").decode("utf-8", errors="replace").strip() if r.returncode == 2: return None, err_txt or "rg 正则解析失败" if r.returncode not in (0, 1): return None, err_txt or f"rg 退出码 {r.returncode}" out = (r.stdout or b"").decode("utf-8", errors="replace") spans: List[Tuple[int, int]] = [] for line in out.splitlines(): line = line.strip() if not line: continue try: obj = json.loads(line) except json.JSONDecodeError: continue if obj.get("type") != "match": continue data = obj.get("data") or {} abs_off = data.get("absolute_offset") if abs_off is None: continue for sm in data.get("submatches") or []: try: sb = abs_off + int(sm["start"]) eb = abs_off + int(sm["end"]) qt_s, qt_e = _utf8_byte_range_to_qt_positions(full_text, sb, eb) except (KeyError, TypeError, ValueError): continue spans.append((qt_s, qt_e)) if len(spans) >= max_spans: return spans, None return spans, None def compile_python_span_pattern( pattern: str, case_sensitive: bool, use_regex: bool, whole_word: bool, ) -> Optional[re.Pattern]: """与界面选项一致;在 rg 不可用时用于高亮与跳转(Python re)。""" if not (pattern or "").strip(): return None q = pattern.strip() flags = 0 if case_sensitive else re.IGNORECASE try: if use_regex: src = _build_rg_regex_pattern(q, whole_word) return re.compile(src, flags) inner = re.escape(q) if whole_word: inner = rf"\b{inner}\b" return re.compile(inner, flags) except re.error: return None def rg_filter_lines( full_text: str, pattern: str, case_sensitive: bool, use_regex: bool, whole_word: bool, ) -> Tuple[Optional[str], Optional[str]]: """ 将全文经 stdin 交给 rg,返回仅匹配行(与 rg 默认输出一致)。 - use_regex=False:``--fixed-strings``,可选 ``-w`` 全词。 - use_regex=True:Rust 正则语法(与 Python 略有差异);全词时包 ``\\b(?:... )\\b``。 返回 (result, error);error 非空表示未调用 rg 或失败。 """ exe = find_rg_executable() if not exe: return None, "未找到 rg.exe,请将 rg.exe 放在 Server Manager 目录或设置环境变量 RG_PATH" q = (pattern or "").strip() if not q: return full_text, None args: List[str] = [str(exe), "--color", "never", "--no-heading"] if not case_sensitive: args.append("-i") if not use_regex: args.append("--fixed-strings") if whole_word: args.append("-w") pat_arg = q else: pat_arg = _build_rg_regex_pattern(q, whole_word) args.extend(["--", pat_arg, "-"]) try: r = subprocess.run( args, input=full_text.encode("utf-8"), capture_output=True, timeout=120, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0) if sys.platform == "win32" else 0, ) except Exception as e: return None, str(e) if r.returncode not in (0, 1): err = (r.stderr or b"").decode("utf-8", errors="replace").strip() return None, err or f"rg 退出码 {r.returncode}" out = (r.stdout or b"").decode("utf-8", errors="replace") if r.returncode == 1: return "", None return out.rstrip("\n"), None def rg_search_directory( search_root: Path, pattern: str, case_sensitive: bool, use_regex: bool, whole_word: bool, glob_globs: Optional[List[str]] = None, ) -> Tuple[str, Optional[str]]: """在目录下递归搜索,返回 rg 标准输出(含文件名与行号)。""" exe = find_rg_executable() if not exe: return "", "未找到 rg.exe" q = (pattern or "").strip() if not q: return "", "请输入搜索内容" if not search_root.is_dir(): return "", f"目录不存在: {search_root}" args: List[str] = [str(exe), "-n", "-S", "--color", "never", "--heading"] if not case_sensitive: args.append("-i") if not use_regex: args.append("--fixed-strings") if whole_word: args.append("-w") pat_arg = q else: pat_arg = _build_rg_regex_pattern(q, whole_word) if glob_globs: for g in glob_globs: args.extend(["--glob", g]) args.extend(["--", pat_arg, str(search_root.resolve())]) try: r = subprocess.run( args, capture_output=True, text=True, timeout=180, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0) if sys.platform == "win32" else 0, ) except Exception as e: return "", str(e) out = (r.stdout or "").strip() err = (r.stderr or "").strip() if r.returncode not in (0, 1): return "", err or f"rg 退出码 {r.returncode}" if r.returncode == 1 and not out: return "(无匹配)", None return out, None