Skip to content

Commit 2a534bb

Browse files
committed
feat(lsp): add Go to Definition (F12) and Find References (Shift+F12) with fallback
1 parent 5b8314a commit 2a534bb

13 files changed

Lines changed: 1164 additions & 7 deletions

CHANGELOG.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,39 @@
1010

1111
Alle wesentlichen Änderungen an CodeBox werden hier dokumentiert.
1212

13+
## [0.3.0] - 2026-09-12
14+
15+
### LSP Definitionen- & Referenzen-Sprung ("Go to Definition" F12 & "Find References" Shift+F12)
16+
17+
- `features/lsp_client.py`:
18+
- LSP-Funktionen erweitert um `request_definition(uri, line, col, callback)` (`textDocument/definition`) und `request_references(uri, line, col, include_declaration, callback)` (`textDocument/references`).
19+
- Client-Capabilities um `definition: {}` und `references: {}` erweitert.
20+
- Sicheres URL- und Pfadparsing mit `lsp_uri_to_path` (robuste Behandlung von Windows-Laufwerksbuchstaben `file:///C:/...` und Sonderzeichen/Leerzeichen via `unquote`).
21+
- Robuste Normalisierung mit `parse_lsp_locations(result)` für alle LSP-Formate (`Location`, `Location[]` und `LocationLink[]`) mit 1-basierter Koordinatenumrechnung.
22+
- `core/symbol_search.py`:
23+
- Neues Kernmodul für semantische und textuelle Definition- & Referenz-Erkennung mit Heuristik-Fallback.
24+
- Sprachspezifische Regex-Muster für Python (`def`, `class`, `async def`), JS/TS (`function`, `class`, `const/let/var =`), Rust (`fn`, `struct`, `enum`, `impl`), Go (`func`, `type`), C/C++ (`struct`, `class`, Funktionssignaturen).
25+
- Deterministischer Wortgrenzen-Scanner `find_references_fallback` ohne versehentliche Teilworttreffer.
26+
- Respektiert Dateigrößen-Limits (2 MB) und `IGNORED_DIRS` (`.git`, `__pycache__`, `.venv`, `node_modules` etc.).
27+
- `ui/references_panel.py`:
28+
- Neues Panel `ReferencesPanel` als permanenter Tab im unteren Bereich (`bottom_tabs`, Tab-Index 3).
29+
- Gruppierte Baumansicht nach Datei mit Badge-Anzahl pro Datei und Zeilennummern.
30+
- Interaktives Navigieren per Doppelklick oder Return/Enter-Taste mit Signal `referenceActivated`.
31+
- Schaltfläche "Leeren" und barrierefreie Accessible-Descriptions.
32+
- `core/editor.py`:
33+
- Editor-Signale `definitionRequested(int, int, str)` und `referencesRequested(int, int, str)`.
34+
- Methoden `get_symbol_at_cursor()`, `request_goto_definition()`, `request_find_references()`.
35+
- Dynamisches Kontextmenü: "Zur Definition von '<symbol>' springen\tF12", "Referenzen für '<symbol>' suchen\tShift+F12", "In Dateien suchen...\tCtrl+Shift+F" und "Zeilenkommentar umschalten\tCtrl+/".
36+
- `ui/main_window.py`:
37+
- Aktionen *Zur Definition springen* (`F12`) und *Alle Referenzen suchen* (`Shift+F12`) im Menü *Bearbeiten*.
38+
- Zentrale Signalweiterleitung in `_connect_cursor(tab)` für alle bestehenden, neuen und geteilten Tabs.
39+
- Automatischer Wechsel zwischen aktiver LSP-Anfrage und schnellem Regex-Fallback bei inaktivem Server oder leeren Ergebnissen.
40+
- Präzise In-Tab-Navigation `_jump_to_position_in_tab` für dasselbe Dokument und `open_path_at` für externe Projektdateien.
41+
- `ui/command_palette.py` & `ui/shortcuts_dialog.py`:
42+
- Schnellaktionen und Tastenkürzel für F12 und Shift+F12 registriert und dokumentiert.
43+
- `tests/test_lsp_definition_references.py`:
44+
- 16 neue automatisierte Unittests für URI-Parsing, Location-Normalisierung, LSP-Request-Formate, Regex-Fallbacks, Referenzen-Panel und MainWindow-Integration.
45+
1346
## [0.2.0] - 2026-09-12
1447

1548
### Integrierte globale Textsuche (Workspace Find in Files / Grep-Tool)

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,13 @@
66
[![Python 3.10+](https://img.shields.io/badge/python-3.10%20%7C%203.11%20%7C%203.12%20%7C%203.13-blue.svg)](https://www.python.org/)
77
[![Platform: Windows | Linux | macOS](https://img.shields.io/badge/platform-Windows%20%7C%20Linux%20%7C%20macOS-lightgrey.svg)]()
88
[![CI](https://github.com/dev-bricks/CodeBox/actions/workflows/ci.yml/badge.svg)](https://github.com/dev-bricks/CodeBox/actions/workflows/ci.yml)
9-
[![Tests](https://img.shields.io/badge/tests-248%20passed%20%7C%20100%25-brightgreen.svg)]()
9+
[![Tests](https://img.shields.io/badge/tests-273%20passed%20%7C%20100%25-brightgreen.svg)]()
1010
[![Privacy: Zero-Egress](https://img.shields.io/badge/privacy-100%25%20local--first%20%7C%20zero--egress-success.svg)](SECURITY.md)
1111
[![Security Policy](https://img.shields.io/badge/security-bilingual%20policy-blue.svg)](SECURITY.md)
1212
[![Ecosystem: dev-bricks](https://img.shields.io/badge/ecosystem-dev--bricks-blue.svg)](https://github.com/dev-bricks)
1313
[![Part of: open-bricks](https://img.shields.io/badge/part%20of-open--bricks-blue.svg)](https://github.com/open-bricks)
1414
[![LSP Ready](https://img.shields.io/badge/LSP-ready-purple.svg)]()
15-
[![Version: 0.2.0](https://img.shields.io/badge/version-0.2.0-green.svg)](CHANGELOG.md)
15+
[![Version: 0.3.0](https://img.shields.io/badge/version-0.3.0-green.svg)](CHANGELOG.md)
1616
[![llms.txt](https://img.shields.io/badge/llms.txt-available-green.svg)](llms.txt)
1717

1818
[Deutsch](README_de.md) | English
@@ -170,6 +170,8 @@ sequenceDiagram
170170
| **Integrated Terminal** | Embedded `QProcess` terminal supporting `cmd`, `PowerShell`, and `bash`. | Dynamic encoding adaptation (cp1252 / utf-8) and working directory synchronisation. |
171171
| **Multi-Root Workspaces** | Native `.codebox-workspace` format with portable relative paths. | Seamless project switching across multiple repository roots (`Ctrl+Shift+O`). |
172172
| **Global Find in Files** | Multi-threaded background grep (`core/file_search.py`) with `Ctrl+Shift+F`. | Fast non-blocking search with Regex/Word/Case options and direct jump. |
173+
| **Go to Definition & References** | LSP `textDocument/definition` & `references` (`F12`, `Shift+F12`) with AST/regex fallback. | Instant symbol navigation across open files and workspace projects. |
174+
173175

174176
---
175177

README_de.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,13 @@
66
[![Python 3.10+](https://img.shields.io/badge/python-3.10%20%7C%203.11%20%7C%203.12%20%7C%203.13-blue.svg)](https://www.python.org/)
77
[![Plattform: Windows | Linux | macOS](https://img.shields.io/badge/platform-Windows%20%7C%20Linux%20%7C%20macOS-lightgrey.svg)]()
88
[![CI](https://github.com/dev-bricks/CodeBox/actions/workflows/ci.yml/badge.svg)](https://github.com/dev-bricks/CodeBox/actions/workflows/ci.yml)
9-
[![Tests](https://img.shields.io/badge/tests-248%20passed%20%7C%20100%25-brightgreen.svg)]()
9+
[![Tests](https://img.shields.io/badge/tests-273%20passed%20%7C%20100%25-brightgreen.svg)]()
1010
[![Datenschutz: Zero-Egress](https://img.shields.io/badge/datenschutz-100%25%20local--first%20%7C%20zero--egress-success.svg)](SECURITY.md)
1111
[![Sicherheitsrichtlinie](https://img.shields.io/badge/sicherheit-zweisprachige%20policy-blue.svg)](SECURITY.md)
1212
[![Ökosystem: dev-bricks](https://img.shields.io/badge/ecosystem-dev--bricks-blue.svg)](https://github.com/dev-bricks)
1313
[![Dachverband: open-bricks](https://img.shields.io/badge/part%20of-open--bricks-blue.svg)](https://github.com/open-bricks)
1414
[![LSP Ready](https://img.shields.io/badge/LSP-ready-purple.svg)]()
15-
[![Version: 0.2.0](https://img.shields.io/badge/version-0.2.0-green.svg)](CHANGELOG.md)
15+
[![Version: 0.3.0](https://img.shields.io/badge/version-0.3.0-green.svg)](CHANGELOG.md)
1616
[![llms.txt](https://img.shields.io/badge/llms.txt-verf%C3%BCgbar-green.svg)](llms.txt)
1717

1818
[English](README.md) | Deutsch
@@ -170,6 +170,8 @@ sequenceDiagram
170170
| **Integriertes Terminal** | Eingebetteter `QProcess`-Terminalbereich für `cmd`, `PowerShell` und `bash`. | Dynamische Kodierungsanpassung (cp1252 / utf-8) und Verzeichnissynchronisation. |
171171
| **Multi-Root-Arbeitsbereiche** | Natives `.codebox-workspace`-Format mit relativen, portablen Pfaden. | Nahtlose Verwaltung mehrerer Projektordner in einer Instanz (`Ctrl+Shift+O`). |
172172
| **Projektweite Dateisuche** | Multithreaded Hintergrund-Grep (`core/file_search.py`) mit `Ctrl+Shift+F`. | Schnelle, nicht-blockierende Suche mit Regex-, Wort- und Glob-Filtern. |
173+
| **Definitionen- & Referenzen-Sprung** | LSP `textDocument/definition` & `references` (`F12`, `Shift+F12`) mit AST/Regex-Fallback. | Sofortige Symbolnavigation über geöffnete Tabs und Projektgrenzen hinweg. |
174+
173175

174176
---
175177

core/editor.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,8 @@ class CodeEditor(QPlainTextEdit):
233233

234234
cursorPositionInfo = Signal(int, int) # Zeile, Spalte
235235
completionRequested = Signal(int, int, str) # LSP: Zeile, Spalte, Prefix (0-basiert)
236+
definitionRequested = Signal(int, int, str) # LSP/Fallback: Zeile, Spalte, Symbol (0-basiert)
237+
referencesRequested = Signal(int, int, str) # LSP/Fallback: Zeile, Spalte, Symbol (0-basiert)
236238
modificationChanged = Signal(bool)
237239
focusReceived = Signal()
238240

@@ -360,6 +362,63 @@ def text_under_cursor(self) -> str:
360362
tc.select(QTextCursor.SelectionType.WordUnderCursor)
361363
return tc.selectedText()
362364

365+
def get_symbol_at_cursor(self) -> str:
366+
"""Gibt das Symbol unter dem Cursor oder den markierten Text zurück."""
367+
cursor = self.textCursor()
368+
if cursor.hasSelection():
369+
sel = cursor.selectedText().strip()
370+
if sel:
371+
return sel
372+
return self.text_under_cursor().strip()
373+
374+
def request_goto_definition(self):
375+
"""Löst den Sprung zur Definition für das aktuelle Symbol aus."""
376+
cursor = self.textCursor()
377+
line = cursor.blockNumber()
378+
col = cursor.positionInBlock()
379+
symbol = self.get_symbol_at_cursor()
380+
self.definitionRequested.emit(line, col, symbol)
381+
382+
def request_find_references(self):
383+
"""Löst die Referenzsuche für das aktuelle Symbol aus."""
384+
cursor = self.textCursor()
385+
line = cursor.blockNumber()
386+
col = cursor.positionInBlock()
387+
symbol = self.get_symbol_at_cursor()
388+
self.referencesRequested.emit(line, col, symbol)
389+
390+
def contextMenuEvent(self, event):
391+
"""Erweitertes Kontextmenü mit Definitionen, Referenzen und CodeBox-Aktionen."""
392+
cursor = self.cursorForPosition(event.pos())
393+
if not self.textCursor().hasSelection():
394+
self.setTextCursor(cursor)
395+
396+
menu = self.createStandardContextMenu()
397+
menu.addSeparator()
398+
399+
symbol = self.get_symbol_at_cursor()
400+
if symbol:
401+
act_def = menu.addAction(f"Zur Definition von '{symbol}' springen\tF12")
402+
act_ref = menu.addAction(f"Referenzen für '{symbol}' suchen\tShift+F12")
403+
else:
404+
act_def = menu.addAction("Zur Definition springen\tF12")
405+
act_ref = menu.addAction("Alle Referenzen suchen\tShift+F12")
406+
407+
act_def.triggered.connect(self.request_goto_definition)
408+
act_ref.triggered.connect(self.request_find_references)
409+
410+
menu.addSeparator()
411+
window = self.window()
412+
if hasattr(window, "show_find_in_files"):
413+
act_find_files = menu.addAction("In Dateien suchen...\tCtrl+Shift+F")
414+
act_find_files.triggered.connect(lambda: window.show_find_in_files(initial_query=symbol))
415+
416+
act_comment = menu.addAction("Zeilenkommentar umschalten\tCtrl+/")
417+
act_comment.triggered.connect(self.toggle_comment)
418+
419+
menu.exec(event.globalPos())
420+
421+
363422
def indent_selection(self, spaces: int = None):
364423
"""Rückt die aktuelle Zeile oder alle markierten Zeilen ein."""
365424
if spaces is None:

core/symbol_search.py

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
#!/usr/bin/env python3
2+
# -*- coding: utf-8 -*-
3+
"""
4+
Symbol Search & Fallback Provider für CodeBox.
5+
6+
Bietet schnelle, heuristische Symbol- und Referenzsuche, falls kein LSP-Server
7+
installiert oder aktiv ist oder der Language Server für eine Datei keine Ergebnisse liefert.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import re
13+
from pathlib import Path
14+
from typing import List, Optional
15+
16+
17+
IGNORED_DIRS = {
18+
".git",
19+
"__pycache__",
20+
".pytest_cache",
21+
".mypy_cache",
22+
".ruff_cache",
23+
"node_modules",
24+
".venv",
25+
"venv",
26+
"env",
27+
"build",
28+
"dist",
29+
".idea",
30+
".vscode",
31+
}
32+
33+
MAX_FILE_SIZE_BYTES = 1024 * 1024 * 2 # 2 MB Begrenzung pro Textdatei
34+
35+
36+
def get_definition_patterns(symbol: str) -> List[re.Pattern]:
37+
"""Erstellt reguläre Ausdrücke zur Erkennung typischer Definitionen für das Symbol."""
38+
escaped = re.escape(symbol)
39+
patterns = [
40+
# Python: def func / async def func / class Class / Var =
41+
rf"^\s*(?:async\s+)?def\s+{escaped}\b",
42+
rf"^\s*class\s+{escaped}\b",
43+
rf"^\s*{escaped}\s*=",
44+
# JS / TS: function func / class Class / const/let/var x = / type / interface
45+
rf"^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s+{escaped}\b",
46+
rf"^\s*(?:export\s+)?class\s+{escaped}\b",
47+
rf"^\s*(?:export\s+)?(?:const|let|var)\s+{escaped}\s*=",
48+
rf"^\s*(?:export\s+)?(?:interface|type)\s+{escaped}\b",
49+
# Rust / Go / C / C++
50+
rf"^\s*(?:pub(?:\([^)]*\))?\s+)?fn\s+{escaped}\b",
51+
rf"^\s*func\s+(?:\([^)]+\)\s+)?{escaped}\b",
52+
rf"^\s*(?:pub\s+)?(?:struct|enum|trait)\s+{escaped}\b",
53+
]
54+
return [re.compile(p, re.MULTILINE) for p in patterns]
55+
56+
57+
def _scan_text_for_definitions(
58+
text: str,
59+
patterns: List[re.Pattern],
60+
symbol: str,
61+
path: Optional[Path],
62+
) -> List[dict]:
63+
results: List[dict] = []
64+
lines = text.splitlines()
65+
for line_idx, line in enumerate(lines):
66+
for pat in patterns:
67+
m = pat.search(line)
68+
if m:
69+
# Spalte des Symbols ermitteln
70+
col_idx = line.find(symbol)
71+
if col_idx < 0:
72+
col_idx = m.start()
73+
results.append({
74+
"path": path,
75+
"line": line_idx + 1,
76+
"col": col_idx + 1,
77+
"length": len(symbol),
78+
"preview": line.strip(),
79+
"source": "Definition (Fallback)",
80+
})
81+
break
82+
return results
83+
84+
85+
def find_definition_fallback(
86+
symbol: str,
87+
current_path: Optional[Path] = None,
88+
current_text: Optional[str] = None,
89+
workspace_folders: Optional[List[Path]] = None,
90+
) -> List[dict]:
91+
"""Sucht nach Definitionen eines Symbols im aktuellen Dokument und Workspace."""
92+
if not symbol or not symbol.strip():
93+
return []
94+
symbol = symbol.strip()
95+
patterns = get_definition_patterns(symbol)
96+
results: List[dict] = []
97+
98+
# 1. Zuerst das aktuelle Dokument durchsuchen
99+
if current_text:
100+
doc_matches = _scan_text_for_definitions(current_text, patterns, symbol, current_path)
101+
results.extend(doc_matches)
102+
if results:
103+
return results
104+
105+
# 2. Falls im aktiven Dokument nichts gefunden wurde: Workspace durchsuchen
106+
if workspace_folders:
107+
current_resolved = current_path.resolve() if current_path and current_path.exists() else None
108+
for folder in workspace_folders:
109+
folder_path = Path(folder).resolve()
110+
if not folder_path.is_dir():
111+
continue
112+
for root, dirs, files in folder_path.walk():
113+
dirs[:] = [d for d in dirs if d not in IGNORED_DIRS and not d.startswith(".")]
114+
for fname in files:
115+
if fname.startswith("."):
116+
continue
117+
file_path = root / fname
118+
if current_resolved and file_path == current_resolved:
119+
continue
120+
try:
121+
if file_path.stat().st_size > MAX_FILE_SIZE_BYTES:
122+
continue
123+
content = file_path.read_text(encoding="utf-8", errors="replace")
124+
except (OSError, UnicodeError):
125+
continue
126+
127+
matches = _scan_text_for_definitions(content, patterns, symbol, file_path)
128+
if matches:
129+
results.extend(matches)
130+
# Sobald wir eine konkrete Definition finden, zurückgeben
131+
if len(results) >= 10:
132+
return results
133+
134+
return results
135+
136+
137+
def find_references_fallback(
138+
symbol: str,
139+
current_path: Optional[Path] = None,
140+
current_text: Optional[str] = None,
141+
workspace_folders: Optional[List[Path]] = None,
142+
max_results: int = 250,
143+
) -> List[dict]:
144+
"""Sucht nach allen Vorkommen (Ganzwort) des Symbols im aktuellen Dokument und Workspace."""
145+
if not symbol or not symbol.strip():
146+
return []
147+
symbol = symbol.strip()
148+
pattern = re.compile(rf"\b{re.escape(symbol)}\b")
149+
results: List[dict] = []
150+
151+
# 1. Aktives Dokument
152+
if current_text:
153+
for line_idx, line in enumerate(current_text.splitlines()):
154+
for match in pattern.finditer(line):
155+
results.append({
156+
"path": current_path,
157+
"line": line_idx + 1,
158+
"col": match.start() + 1,
159+
"length": len(symbol),
160+
"preview": line.strip(),
161+
"source": "Referenz (Text)",
162+
})
163+
if len(results) >= max_results:
164+
return results
165+
166+
# 2. Workspace
167+
if workspace_folders:
168+
current_resolved = current_path.resolve() if current_path and current_path.exists() else None
169+
for folder in workspace_folders:
170+
folder_path = Path(folder).resolve()
171+
if not folder_path.is_dir():
172+
continue
173+
for root, dirs, files in folder_path.walk():
174+
dirs[:] = [d for d in dirs if d not in IGNORED_DIRS and not d.startswith(".")]
175+
for fname in files:
176+
if fname.startswith("."):
177+
continue
178+
file_path = root / fname
179+
if current_resolved and file_path == current_resolved:
180+
continue
181+
try:
182+
if file_path.stat().st_size > MAX_FILE_SIZE_BYTES:
183+
continue
184+
content = file_path.read_text(encoding="utf-8", errors="replace")
185+
except (OSError, UnicodeError):
186+
continue
187+
188+
for line_idx, line in enumerate(content.splitlines()):
189+
for match in pattern.finditer(line):
190+
results.append({
191+
"path": file_path,
192+
"line": line_idx + 1,
193+
"col": match.start() + 1,
194+
"length": len(symbol),
195+
"preview": line.strip(),
196+
"source": "Referenz (Text)",
197+
})
198+
if len(results) >= max_results:
199+
return results
200+
201+
return results

0 commit comments

Comments
 (0)