288 lines
10 KiB
Python
288 lines
10 KiB
Python
# path_hints.py
|
||
from __future__ import annotations
|
||
from dataclasses import dataclass, field
|
||
from typing import Dict, List, Optional, Tuple
|
||
|
||
import re
|
||
|
||
|
||
# ---------------------- tokenization helpers ----------------------
|
||
|
||
def split_path_tokens(path: str) -> List[str]:
|
||
"""
|
||
Разбивает строку пути на логические части:
|
||
'foo[2].bar[1]->baz' -> ['foo', '[2]', 'bar', '[1]', 'baz']
|
||
Аналог твоей split_path(), но оставлена как чистая функция.
|
||
"""
|
||
tokens: List[str] = []
|
||
token = ''
|
||
i = 0
|
||
L = len(path)
|
||
while i < L:
|
||
c = path[i]
|
||
# '->'
|
||
if c == '-' and i + 1 < L and path[i:i+2] == '->':
|
||
if token:
|
||
tokens.append(token)
|
||
token = ''
|
||
i += 2
|
||
continue
|
||
# одиночный '-' в конце
|
||
if c == '-' and i == L - 1:
|
||
i += 1
|
||
continue
|
||
# '.'
|
||
if c == '.':
|
||
if token:
|
||
tokens.append(token)
|
||
token = ''
|
||
i += 1
|
||
continue
|
||
# '[' ... ']'
|
||
if c == '[':
|
||
if token:
|
||
tokens.append(token)
|
||
token = ''
|
||
idx = ''
|
||
while i < L and path[i] != ']':
|
||
idx += path[i]
|
||
i += 1
|
||
if i < L and path[i] == ']':
|
||
idx += ']'
|
||
i += 1
|
||
tokens.append(idx)
|
||
continue
|
||
# обычный символ
|
||
token += c
|
||
i += 1
|
||
if token:
|
||
tokens.append(token)
|
||
return tokens
|
||
|
||
|
||
def canonical_key(path: str) -> str:
|
||
"""
|
||
Преобразует путь к канонической форме для индекса / поиска:
|
||
- '->' -> '.'
|
||
- '[' -> '.['
|
||
- lower()
|
||
"""
|
||
p = path.replace('->', '.')
|
||
p = p.replace('[', '.[')
|
||
return p.lower()
|
||
|
||
|
||
# ---------------------- индекс узлов ----------------------
|
||
|
||
@dataclass
|
||
class PathNode:
|
||
"""
|
||
Узел в логическом дереве путей.
|
||
Храним:
|
||
- собственное имя (локальное, напр. 'controller' или '[3]')
|
||
- полный путь (оригинальный, как его должен видеть пользователь)
|
||
- тип (опционально; widget может хранить отдельно)
|
||
- дети
|
||
"""
|
||
name: str
|
||
full_path: str
|
||
type_str: str = ''
|
||
children: Dict[str, "PathNode"] = field(default_factory=dict)
|
||
|
||
def add_child(self, child: "PathNode") -> None:
|
||
self.children[child.name] = child
|
||
|
||
def get_children(self) -> List["PathNode"]:
|
||
"""
|
||
Вернуть список дочерних узлов, отсортированных по имени.
|
||
"""
|
||
return sorted(self.children.values(), key=lambda n: n.name)
|
||
|
||
class PathHints:
|
||
"""
|
||
Движок автоподсказок / completion.
|
||
Работает с плоским списком ПОЛНЫХ имён (как показываются пользователю).
|
||
Сам восстанавливает иерархию и выдаёт подсказки по текущему вводу.
|
||
Qt-независим.
|
||
"""
|
||
|
||
def __init__(self) -> None:
|
||
self._paths: List[str] = []
|
||
self._types: Dict[str, str] = {} # full_path -> type_str (опционально)
|
||
self._index: Dict[str, PathNode] = {} # canonical full path -> node
|
||
self._root_children: Dict[str, PathNode] = {} # top-level по первому токену
|
||
|
||
# ------------ Подаём данные ------------
|
||
def set_paths(self,
|
||
paths: List[Tuple[str, Optional[str]]]
|
||
) -> None:
|
||
"""
|
||
paths: список кортежей (full_path, type_str|None).
|
||
Пример: ('project.controller.read.errors.bit.status_er0', 'unsigned int')
|
||
Поля могут содержать '->' и индексы, т.е. строки в пользовательском формате.
|
||
|
||
NOTE: порядок не важен; дерево строится автоматически.
|
||
"""
|
||
self._paths = []
|
||
self._types.clear()
|
||
self._index.clear()
|
||
self._root_children.clear()
|
||
|
||
for p, t in paths:
|
||
if t is None:
|
||
t = ''
|
||
self._add_path(p, t)
|
||
|
||
def _add_path(self, full_path: str, type_str: str) -> None:
|
||
self._paths.append(full_path)
|
||
self._types[full_path] = type_str
|
||
|
||
toks = split_path_tokens(full_path)
|
||
if not toks:
|
||
return
|
||
|
||
cur_dict = self._root_children
|
||
cur_full = ''
|
||
parent_node: Optional[PathNode] = None
|
||
|
||
for i, tok in enumerate(toks):
|
||
# Собираем ПОЛНЫЙ путь
|
||
if cur_full == '':
|
||
cur_full = tok
|
||
else:
|
||
if tok.startswith('['):
|
||
cur_full += tok
|
||
else:
|
||
cur_full += '.' + tok
|
||
|
||
# Если узел уже есть
|
||
node = cur_dict.get(tok)
|
||
if node is None:
|
||
# --- ВАЖНО: full_path = cur_full ---
|
||
node = PathNode(name=tok, full_path=cur_full)
|
||
cur_dict[tok] = node
|
||
|
||
# Регистрируем все узлы, включая промежуточные
|
||
self._index[canonical_key(cur_full)] = node
|
||
|
||
parent_node = node
|
||
cur_dict = node.children
|
||
|
||
# В последний узел добавляем тип
|
||
if parent_node:
|
||
parent_node.type_str = type_str
|
||
|
||
|
||
# ------------ Поиск узла ------------
|
||
|
||
def find_node(self, path: str) -> Optional[PathNode]:
|
||
return self._index.get(canonical_key(path))
|
||
|
||
def get_children(self, full_path: str) -> List[PathNode]:
|
||
"""
|
||
Вернуть список дочерних узлов PathNode для заданного полного пути.
|
||
Если узел не найден — вернуть пустой список.
|
||
"""
|
||
node = self.find_node(full_path)
|
||
if node is None:
|
||
return []
|
||
return node.get_children()
|
||
# ------------ Подсказки ------------
|
||
|
||
def suggest(self,
|
||
text: str,
|
||
*,
|
||
include_partial: bool = True
|
||
) -> List[str]:
|
||
"""
|
||
Вернёт список *полных имён узлов*, подходящих под ввод.
|
||
Правила (упрощённо, повторяя твою update_completions()):
|
||
- Если текст пуст → top-level.
|
||
- Если заканчивается на '.' или '->' или '[' → вернуть детей текущего узла.
|
||
- Иначе → фильтр по последнему фрагменту (prefix substring match).
|
||
"""
|
||
text = text or ''
|
||
stripped = text.strip()
|
||
|
||
# пусто: top-level
|
||
if stripped == '':
|
||
return sorted(self._root_full_names())
|
||
|
||
# Завершение по разделителю?
|
||
if stripped.endswith('.') or stripped.endswith('->') or stripped.endswith('['):
|
||
base = stripped[:-1] if stripped.endswith('[') else stripped.rstrip('.').rstrip('>').rstrip('-')
|
||
node = self.find_node(base)
|
||
if node:
|
||
return self._children_full_names(node)
|
||
# не нашли базу — ничего
|
||
return []
|
||
|
||
# иначе: обычный поиск по последней части
|
||
toks = split_path_tokens(stripped)
|
||
prefix_last = toks[-1].lower() if toks else ''
|
||
parent_toks = toks[:-1]
|
||
|
||
if not parent_toks:
|
||
# фильтр top-level
|
||
res = []
|
||
for name, node in self._root_children.items():
|
||
if prefix_last == '' or prefix_last in name.lower():
|
||
res.append(node.full_path)
|
||
return sorted(res)
|
||
|
||
# есть родитель
|
||
parent_path = self._join_tokens(parent_toks)
|
||
parent_node = self.find_node(parent_path)
|
||
if not parent_node:
|
||
return []
|
||
res = []
|
||
for child in parent_node.children.values():
|
||
if prefix_last == '' or prefix_last in child.name.lower():
|
||
res.append(child.full_path)
|
||
return sorted(res)
|
||
|
||
def add_separator(self, full_path: str) -> str:
|
||
"""
|
||
Возвращает full_path с добавленным разделителем ('.' или '['),
|
||
если у узла есть дети и пользователь ещё не поставил разделитель.
|
||
Если первый ребёнок — массивный токен ('[0]') → добавляем '['.
|
||
Позже можно допилить '->' для указателей.
|
||
"""
|
||
node = self.find_node(full_path)
|
||
text = full_path
|
||
|
||
if node and node.children and not (
|
||
text.endswith('.') or text.endswith('->') or text.endswith('[')
|
||
):
|
||
first_child = next(iter(node.children.values()))
|
||
if first_child.name.startswith('['):
|
||
text += '[' # сразу начинаем индекс
|
||
else:
|
||
text += '.' # обычный переход
|
||
return text
|
||
|
||
|
||
# ------------ внутренние вспомогательные ------------
|
||
|
||
def _root_full_names(self) -> List[str]:
|
||
return [node.full_path for node in self._root_children.values()]
|
||
|
||
def _children_full_names(self, node: PathNode) -> List[str]:
|
||
return [ch.full_path for ch in node.children.values()]
|
||
|
||
@staticmethod
|
||
def _join_tokens(tokens: List[str]) -> str:
|
||
"""
|
||
Собираем путь обратно. Для внутренних нужд (поиск), формат не критичен —
|
||
всё равно canonical_key() нормализует.
|
||
"""
|
||
if not tokens:
|
||
return ''
|
||
out = tokens[0]
|
||
for t in tokens[1:]:
|
||
if t.startswith('['):
|
||
out += t
|
||
else:
|
||
out += '.' + t
|
||
return out
|