Files
server_manager/src/services/rg_search.py
T
2026-05-22 00:16:08 +08:00

267 lines
8.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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=TrueRust 正则语法(与 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