Skip to content

Commit e4e84b2

Browse files
committed
fix(highlighter): escape regex keyword patterns, improve comment parsing and plugin cleanup
1 parent 5a9896a commit e4e84b2

10 files changed

Lines changed: 189 additions & 31 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,15 @@ Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.1.0/).
1313

1414
## [0.1.2] - 2026-08-16
1515

16+
### Bugfixes & Plugin-Resilienz (2026-08-20)
17+
18+
- `core/highlighter.py`: Regex-Mustererstellung in `UniversalHighlighter` (`_keyword_pattern`) gehärtet, sodass Keywords und Builtins mit Satzzeichen/Metazeichen (z.B. Rubys `defined?` oder C++-Symbole) mit `re.escape()` maskiert und mit sicheren Wortgrenzen gematcht werden. Verhindert Fehl-Highlighting von Variablennamen (`define`) und unvollständiges Keyword-Highlighting.
19+
- `languages/declarative.py`: Parsing von `comment_style` in `DeclarativeLanguageProvider.from_dict` erweitert, sodass Einzelstring- (`#`), 1-Element-Listen (`["--"]`), 3-Element-Flachlisten (`["--", "--[[", "--]]"]`) und Dictionary-Formate deterministisch ausgewertet werden.
20+
- `features/plugin_manager.py`: `discover_and_load_all()` bereinigt gelöschte Plugin-Dateien beim Re-Scan; `_load_python_plugin()` registriert Provider aus `PluginInfo`-Rückgaben von `setup()` ab.
21+
- `languages/__init__.py`: Null- und Whitespace-Sicherheit für `get_provider_for_extension`, `get_provider_by_name` und `is_provider_registered`.
22+
- `ui/plugins_dialog.py`: Abbruch-Verhalten bei Vorlagenerstellung korrigiert; Detailanzeige gegen leere Provider-Felder abgesichert.
23+
- `tests/test_plugin_system.py`: 4 neue Testsuiten für Keyword-Escaping, Kommentarstil-Variationen, Provider-Lookups und Plugin-Dateibereinigung (103 passed, 1 skipped).
24+
1625
### Technische Hygiene, Metadaten & Discoverability (2026-08-16)
1726

1827
- `tests/test_metadata.py`: Automatisierte Metadaten-, Manifest- und Plugin-Integritätstestsuite ergänzt (Version-Parität `pyproject.toml`, `version.py`, `CHANGELOG.md`, Required-Fields, Core-Docs, Plugin-JSON-Validierung).

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
[![Ecosystem: dev-bricks](https://img.shields.io/badge/ecosystem-dev--bricks-blue.svg)](https://github.com/dev-bricks)
99
[![Part of: open-bricks](https://img.shields.io/badge/part%20of-open--bricks-blue.svg)](https://github.com/open-bricks)
1010
[![LSP Ready](https://img.shields.io/badge/LSP-ready-purple.svg)]()
11-
[![Tests](https://img.shields.io/badge/tests-99%20passed-brightgreen.svg)]()
11+
[![Tests](https://img.shields.io/badge/tests-103%20passed-brightgreen.svg)]()
1212
[![llms.txt](https://img.shields.io/badge/llms.txt-available-green.svg)](llms.txt)
1313

1414
[Deutsch](README_de.md) | English

README_de.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
[![Ecosystem: dev-bricks](https://img.shields.io/badge/ecosystem-dev--bricks-blue.svg)](https://github.com/dev-bricks)
99
[![Part of: open-bricks](https://img.shields.io/badge/part%20of-open--bricks-blue.svg)](https://github.com/open-bricks)
1010
[![LSP Ready](https://img.shields.io/badge/LSP-ready-purple.svg)]()
11-
[![Tests](https://img.shields.io/badge/tests-99%20passed-brightgreen.svg)]()
11+
[![Tests](https://img.shields.io/badge/tests-103%20passed-brightgreen.svg)]()
1212
[![llms.txt](https://img.shields.io/badge/llms.txt-available-green.svg)](llms.txt)
1313

1414
[English](README.md) | Deutsch

core/highlighter.py

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,31 +27,43 @@ def set_provider(self, provider):
2727
self._build_rules()
2828
self.rehighlight()
2929

30+
@staticmethod
31+
def _keyword_pattern(word: str) -> str:
32+
"""Erzeugt ein sicheres Regex-Muster mit passenden Wortgrenzen für Keywords."""
33+
prefix = r'\b' if re.match(r'^\w', word) else r'(?<!\S)'
34+
suffix = r'\b' if re.search(r'\w$', word) else r'(?!\w)'
35+
return f"{prefix}{re.escape(word)}{suffix}"
36+
3037
def _build_rules(self):
3138
"""Erstellt Highlighting-Regeln aus dem Provider"""
3239
self.highlighting_rules = []
40+
if not self.provider:
41+
return
3342

3443
# Keywords (Blau, Fett)
3544
kw_fmt = QTextCharFormat()
3645
kw_fmt.setForeground(QColor(86, 156, 214))
3746
kw_fmt.setFontWeight(QFont.Weight.Bold)
3847
for word in self.provider.get_keywords():
39-
self.highlighting_rules.append(
40-
(QRegularExpression(r'\b' + word + r'\b'), kw_fmt)
41-
)
48+
if word:
49+
self.highlighting_rules.append(
50+
(QRegularExpression(self._keyword_pattern(word)), kw_fmt)
51+
)
4252

4353
# Builtins (Gelb)
4454
bi_fmt = QTextCharFormat()
4555
bi_fmt.setForeground(QColor(220, 220, 170))
4656
for word in self.provider.get_builtins():
47-
self.highlighting_rules.append(
48-
(QRegularExpression(r'\b' + word + r'\b'), bi_fmt)
49-
)
57+
if word:
58+
self.highlighting_rules.append(
59+
(QRegularExpression(self._keyword_pattern(word)), bi_fmt)
60+
)
5061

5162
# Decorators / Preprocessor (Lila)
5263
dec_fmt = QTextCharFormat()
5364
dec_fmt.setForeground(QColor(189, 147, 249))
54-
comment_char = self.provider.get_comment_style()[0]
65+
comment_style = self.provider.get_comment_style()
66+
comment_char = comment_style[0] if (comment_style and len(comment_style) > 0) else ""
5567
if comment_char == '#':
5668
self.highlighting_rules.append(
5769
(QRegularExpression(r'@[^\n]+'), dec_fmt)

features/plugin_manager.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,15 @@ def get_primary_plugin_dir(self) -> Path:
9999
def discover_and_load_all(self) -> List[PluginInfo]:
100100
"""
101101
Durchsucht alle konfigurierten Verzeichnisse nach .json- und .py-Plugins.
102+
Bereinigt zuvor geladene Plugins, deren Dateien nicht mehr existieren.
102103
"""
104+
# Vorhandene Plugin-Dateien auf Existenz prüfen und gelöschte bereinigen
105+
for name, info in list(self._plugins.items()):
106+
if info.file_path and not Path(info.file_path).exists():
107+
self.unload_plugin(name)
108+
self._plugins.pop(name, None)
109+
self._failed_plugins.pop(info.file_path, None)
110+
103111
loaded = []
104112
for directory in self._plugin_dirs:
105113
if directory.exists() and directory.is_dir():
@@ -186,7 +194,12 @@ def _load_python_plugin(self, file_path: Path) -> PluginInfo:
186194
if isinstance(setup_res, LanguageProvider):
187195
provider = setup_res
188196
elif isinstance(setup_res, PluginInfo):
197+
if setup_res.provider:
198+
register_provider(setup_res.provider, override=True)
199+
if not setup_res.file_path:
200+
setup_res.file_path = str(file_path)
189201
self._plugins[setup_res.name] = setup_res
202+
self._failed_plugins.pop(str(file_path), None)
190203
return setup_res
191204
else:
192205
provider = getattr(module, "PROVIDER", None)

languages/__init__.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -139,9 +139,11 @@ def reset_providers() -> None:
139139
_notify_listeners()
140140

141141

142-
def is_provider_registered(name: str) -> bool:
142+
def is_provider_registered(name: Optional[str]) -> bool:
143143
"""Prüft, ob eine Sprache unter dem Namen registriert ist."""
144-
return name in PROVIDERS_BY_NAME
144+
if not name or not isinstance(name, str):
145+
return False
146+
return name.strip() in PROVIDERS_BY_NAME
145147

146148

147149
def add_provider_listener(callback: Callable[[], None]) -> None:
@@ -156,14 +158,21 @@ def remove_provider_listener(callback: Callable[[], None]) -> None:
156158
_LISTENERS.remove(callback)
157159

158160

159-
def get_provider_for_extension(ext: str) -> Optional[LanguageProvider]:
161+
def get_provider_for_extension(ext: Optional[str]) -> Optional[LanguageProvider]:
160162
"""Returns the LanguageProvider for a file extension (without dot)."""
161-
return PROVIDERS.get(ext.lower().lstrip("."))
163+
if not ext or not isinstance(ext, str):
164+
return None
165+
normalized = ext.strip().lower().lstrip(".")
166+
if not normalized:
167+
return None
168+
return PROVIDERS.get(normalized)
162169

163170

164-
def get_provider_by_name(name: str) -> Optional[LanguageProvider]:
171+
def get_provider_by_name(name: Optional[str]) -> Optional[LanguageProvider]:
165172
"""Returns the LanguageProvider by language name."""
166-
return PROVIDERS_BY_NAME.get(name)
173+
if not name or not isinstance(name, str):
174+
return None
175+
return PROVIDERS_BY_NAME.get(name.strip())
167176

168177

169178
def get_all_providers() -> List[LanguageProvider]:

languages/declarative.py

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -153,18 +153,34 @@ def from_dict(cls, data: dict) -> DeclarativeLanguageProvider:
153153
comment_multi_start = "/*"
154154
comment_multi_end = "*/"
155155
comment_style = data.get("comment_style")
156-
if isinstance(comment_style, (list, tuple)) and len(comment_style) >= 1:
157-
comment_single = str(comment_style[0])
158-
if len(comment_style) >= 2 and isinstance(comment_style[1], (list, tuple)) and len(comment_style[1]) >= 2:
159-
comment_multi_start = str(comment_style[1][0])
160-
comment_multi_end = str(comment_style[1][1])
161-
elif len(comment_style) >= 2 and comment_style[1] is None:
156+
if isinstance(comment_style, str):
157+
comment_single = comment_style
158+
comment_multi_start = None
159+
comment_multi_end = None
160+
elif isinstance(comment_style, (list, tuple)):
161+
if len(comment_style) >= 1:
162+
comment_single = str(comment_style[0])
163+
if len(comment_style) >= 2:
164+
second = comment_style[1]
165+
if isinstance(second, (list, tuple)) and len(second) >= 2:
166+
comment_multi_start = str(second[0]) if second[0] else None
167+
comment_multi_end = str(second[1]) if second[1] else None
168+
elif second is None:
169+
comment_multi_start = None
170+
comment_multi_end = None
171+
elif len(comment_style) >= 3 and isinstance(second, str) and isinstance(comment_style[2], str):
172+
comment_multi_start = str(second)
173+
comment_multi_end = str(comment_style[2])
174+
else:
175+
comment_multi_start = None
176+
comment_multi_end = None
177+
else:
162178
comment_multi_start = None
163179
comment_multi_end = None
164180
elif isinstance(comment_style, dict):
165181
comment_single = str(comment_style.get("single", "//"))
166-
comment_multi_start = comment_style.get("multi_start", "/*")
167-
comment_multi_end = comment_style.get("multi_end", "*/")
182+
comment_multi_start = comment_style.get("multi_start")
183+
comment_multi_end = comment_style.get("multi_end")
168184

169185
return cls(
170186
name=name,

llms.txt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# CodeBox - local PySide6 desktop code editor
22

3-
## Last-checked: 2026-08-16
3+
## Last-checked: 2026-08-20
44

55
> CodeBox is a local-first desktop IDE for Windows developers who want a lightweight PySide6 code editor with tabs, a project tree, an integrated terminal, Git helpers, syntax highlighting, Language Server Protocol diagnostics, and an extensible JSON/Python language plugin system.
66

@@ -31,7 +31,7 @@ Part of the dev-bricks family. Python, MIT.
3131
- `python main.py`: Launches the CodeBox desktop interface.
3232
- `python main.py --open <file>`: Launches and directly opens a target file path.
3333
- `build_exe.bat`: Uses PyInstaller to bundle the application into a standalone executable.
34-
- `python -m pytest`: Runs the automated test suite (99 passed, 1 skipped).
34+
- `python -m pytest`: Runs the automated test suite (103 passed, 1 skipped).
3535

3636
## Related Projects (dev-bricks & ellmos family)
3737

@@ -69,4 +69,4 @@ CodeBox local desktop IDE
6969
- Web search showed GitHub organization/topic visibility, but no robust independent external listing for the direct repo yet.
7070
- The strongest disambiguators are `dev-bricks`, `PySide6`, `local-first`, `Windows desktop IDE`, and `Language Server Protocol`.
7171

72-
## Last-checked: 2026-08-14
72+
## Last-checked: 2026-08-20

tests/test_plugin_system.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,3 +261,90 @@ def test_main_window_updates_on_provider_change(qapp):
261261
assert window.lang_combo.findText("CustomLang") == -1
262262

263263
window.close()
264+
265+
266+
def test_highlighter_keyword_escaping_and_boundaries(qapp):
267+
from PySide6.QtGui import QTextDocument
268+
from core.highlighter import UniversalHighlighter
269+
from pathlib import Path
270+
271+
ruby_json = Path(__file__).resolve().parent.parent / "plugins" / "ruby_plugin.json"
272+
assert ruby_json.exists()
273+
provider = DeclarativeLanguageProvider.from_json_file(ruby_json)
274+
275+
doc = QTextDocument()
276+
hl = UniversalHighlighter(doc, provider)
277+
doc.setPlainText("if defined? x\n define = 1\nend")
278+
279+
# Prüfe Zeile 2: ' define = 1'
280+
# 'define' darf NICHT als Keyword 'defined?' gematcht werden
281+
block2 = doc.findBlockByLineNumber(1)
282+
for r, fmt in hl.highlighting_rules:
283+
it = r.globalMatch(block2.text())
284+
while it.hasNext():
285+
m = it.next()
286+
assert m.captured() != "define", "Variable 'define' must not match 'defined?'"
287+
288+
# Prüfe Zeile 1: 'if defined? x' -> 'defined?' muss vollständig matchen
289+
block1 = doc.findBlockByLineNumber(0)
290+
matched_keywords = []
291+
for r, fmt in hl.highlighting_rules:
292+
it = r.globalMatch(block1.text())
293+
while it.hasNext():
294+
m = it.next()
295+
matched_keywords.append(m.captured())
296+
assert "defined?" in matched_keywords
297+
assert "if" in matched_keywords
298+
299+
300+
def test_declarative_comment_style_variations():
301+
# String format
302+
p1 = DeclarativeLanguageProvider.from_dict({"name": "Lang1", "extensions": ["l1"], "comment_style": "#"})
303+
assert p1.get_comment_style() == ("#", None)
304+
305+
# 1-Element list
306+
p2 = DeclarativeLanguageProvider.from_dict({"name": "Lang2", "extensions": ["l2"], "comment_style": ["--"]})
307+
assert p2.get_comment_style() == ("--", None)
308+
309+
# Flat 3-element list
310+
p3 = DeclarativeLanguageProvider.from_dict({
311+
"name": "Lang3", "extensions": ["l3"], "comment_style": ["--", "--[[", "--]]"]
312+
})
313+
assert p3.get_comment_style() == ("--", ("--[[", "--]]"))
314+
315+
# Dict format without multi
316+
p4 = DeclarativeLanguageProvider.from_dict({
317+
"name": "Lang4", "extensions": ["l4"], "comment_style": {"single": ";"}
318+
})
319+
assert p4.get_comment_style() == (";", None)
320+
321+
322+
def test_safe_provider_lookups():
323+
assert get_provider_for_extension(None) is None
324+
assert get_provider_for_extension("") is None
325+
assert get_provider_for_extension(" ") is None
326+
assert get_provider_by_name(None) is None
327+
assert get_provider_by_name("") is None
328+
assert is_provider_registered(None) is False
329+
assert is_provider_registered("") is False
330+
331+
332+
def test_plugin_manager_cleanup_on_vanished_file(tmp_path):
333+
mgr = PluginManager(plugin_dirs=[tmp_path])
334+
json_plugin = tmp_path / "temp_plugin.json"
335+
json_plugin.write_text(json.dumps({
336+
"name": "TempLang",
337+
"extensions": ["tmpl"],
338+
"keywords": ["test"],
339+
}), encoding="utf-8")
340+
341+
loaded = mgr.discover_and_load_all()
342+
assert len(loaded) == 1
343+
assert is_provider_registered("TempLang")
344+
345+
# Datei löschen und erneut scannen
346+
json_plugin.unlink()
347+
loaded_after = mgr.discover_and_load_all()
348+
assert len(loaded_after) == 0
349+
assert not is_provider_registered("TempLang")
350+

ui/plugins_dialog.py

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -177,15 +177,22 @@ def _on_selection_changed(self):
177177
kw_count = len(provider.get_keywords())
178178
bi_count = len(provider.get_builtins())
179179
snip_count = len(provider.get_snippets())
180-
run_cmd = " ".join(provider.get_run_command("beispiel.ext"))
180+
run_cmd_list = provider.get_run_command("beispiel.ext")
181+
run_cmd = " ".join(str(part) for part in run_cmd_list) if run_cmd_list else "-"
181182
comment = provider.get_comment_style()
183+
comment_single = comment[0] if (comment and len(comment) > 0 and comment[0]) else "-"
184+
comment_multi = (
185+
f"{comment[1][0]} ... {comment[1][1]}"
186+
if (comment and len(comment) > 1 and comment[1] and len(comment[1]) >= 2)
187+
else "Keine"
188+
)
182189

183190
lines = [
184191
f"<b>Sprache:</b> {name}",
185192
f"<b>Dateiendungen:</b> {exts}",
186193
f"<b>Keywords:</b> {kw_count} | <b>Built-ins:</b> {bi_count} | <b>Snippets:</b> {snip_count}",
187194
f"<b>Ausführen-Befehl:</b> <code>{run_cmd}</code>",
188-
f"<b>Kommentar-Zeichen:</b> <code>{comment[0]}</code>",
195+
f"<b>Kommentar-Zeichen:</b> <code>{comment_single}</code> (Mehrzeilig: <code>{comment_multi}</code>)",
189196
]
190197
if plugin_info:
191198
if plugin_info.description:
@@ -239,9 +246,14 @@ def _create_plugin_template(self):
239246
ext, ok = QInputDialog.getText(
240247
self, "Dateiendungen", f"Dateiendungen für {name} kommagetrennt (z.B. {name.lower()}, {name.lower()}s):"
241248
)
242-
if not ok or not ext.strip():
243-
ext = name.lower()
244-
extensions = [e.strip().lstrip(".") for e in ext.split(",") if e.strip()]
249+
if not ok:
250+
return
251+
if not ext.strip():
252+
extensions = [name.lower()]
253+
else:
254+
extensions = [e.strip().lstrip(".") for e in ext.split(",") if e.strip()]
255+
if not extensions:
256+
extensions = [name.lower()]
245257

246258
try:
247259
primary_dir = self.plugin_manager.get_primary_plugin_dir()

0 commit comments

Comments
 (0)