Files
werkbaum/frontend/src/autocomplete.js
T
mhoennigandClaude Fable 5 9999c7973e fix: Klick-Erkennung des Abhängigkeits-Sprungs akzeptiert \s wie der Parser (D67)
Vor dem `:#…`-Token prüfte depIdAt nur Space/Tab, der Parser jedes \s —
vor einem geschützten Leerzeichen war das Token damit eine Abhängigkeit,
auf die Strg+Klick nichts tat. Test mit echtem NBSP; Gegenprobe: die alte
Prüfung lässt genau ihn fallen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 09:54:29 +02:00

151 lines
6.6 KiB
JavaScript

/* ID-Vorschläge beim Tippen von Abhängigkeiten (D63) und der Sprung entlang
einer Abhängigkeit (Strg+Klick auf `:#ziel`, D67).
Eingabehilfen wie die ID-Kurzform (D55), keine Notation: Der Parser sieht
nie etwas davon, SPEC und llms.md bleiben unberührt. Hier steht, WAS
gilt — wann ein `:#…`-Kontext vorliegt, welche IDs dazu passen und welche
ID unter der Schreibmarke steht; app.js verdrahtet nur (Popup, Tasten,
Einfügen, Sprung). Frontend-Hausregel: Was entscheidbar ist, gehört in ein
Modul (D54-Nachtrag 3). */
const ID_CHARS = '[\\p{L}\\p{N}._-]';
/* Der Abhängigkeits-Kontext an der Schreibmarke: null, oder
{start, end, fragment, exclude}. `start`..caret ist das angefangene
ID-Fragment hinter dem letzten `#`; `end` reicht über die Schreibmarke
hinaus bis ans Ende der ID-Zeichen (wer mitten im Wort ersetzt, soll kein
`#authth` bekommen). `exclude` sind die IDs, die eine Auswahl nicht mehr
anbieten soll: die schon gelisteten des Tokens und die eigene ID der Zeile
(die Selbst-Abhängigkeit ist zulässig, aber nie das, was man tippen will).
Erkannt wird dieselbe Form, die der Parser liest (SPEC §1): das Token
alleinstehend angesetzt — `(^|\s):#…` — oder unmittelbar hinter der
Knoten-ID (`#auth:#db`, D36). `(:#a` bleibt damit Zitat, `Regel: #x`
bleibt Label. Kein Kontext im Kommentar (hinter `%%`) und nicht im
Beschreibungsteil hinter `---` — dort ist alles Freitext. */
const TOKEN_RE = new RegExp(
'(?:^|[ \\t])(?:#(' + ID_CHARS + '+))?:(#' + ID_CHARS + '*(?:,#' + ID_CHARS + '*)*)$', 'u');
const OWN_ID_RE = new RegExp('(?:^|\\s)#(' + ID_CHARS + '+)', 'u');
const TAIL_RE = new RegExp('^' + ID_CHARS + '*', 'u');
export function depFragment(text, caret){
const before = text.slice(0, caret);
const lines = before.split('\n');
const cur = lines[lines.length - 1];
/* Beschreibungsteil (§1): alles hinter dem ersten `---`-Trenner ist Freitext. */
for(let i = 0; i < lines.length - 1; i++){
if(/^\s*-{3,}\s*$/.test(lines[i])) return null;
}
if(cur.includes('%%')) return null; /* die Schreibmarke steht im Kommentar */
const m = TOKEN_RE.exec(cur);
if(!m) return null;
const token = m[2]; /* "#a,#b…" bis zur Schreibmarke */
const parts = token.slice(1).split(',#');
const fragment = parts[parts.length - 1];
const exclude = parts.slice(0, -1);
if(m[1]) exclude.push(m[1]); /* Kopf-Form `#auth:#…` */
else {
/* Eigene ID der Zeile, wenn sie weiter vorn steht (`#auth: … :#…`) —
erster alleinstehender `#`-Treffer, wie in der Extraktion (Schritt 6).
Der Zeilenrest vor dem Token genügt: Die IDs im Token selbst sind nie
alleinstehend (`:`/`,` davor). */
const own = OWN_ID_RE.exec(cur.slice(0, m.index + 1));
if(own) exclude.push(own[1]);
}
const end = caret + TAIL_RE.exec(text.slice(caret))[0].length;
return {start: caret - fragment.length, end, fragment, exclude};
}
/* Alle vergebenen IDs in Dokumentreihenfolge, mit Titel als Kontext.
Bewusst ALLE Knoten — auch verworfene und eingeklappte: Eine Abhängigkeit
darf überallhin zeigen, und die Faltung ist nur Ansicht (D38). */
export function collectIds(roots){
const out = [];
const walk = ns => {
for(const n of ns){
if(n.id) out.push({id: n.id, label: n.labelFromId ? '' : n.label});
walk(n.children);
}
};
walk(roots);
return out;
}
/* Welche Abhängigkeits-ID steht an dieser Stelle des Textes? (D67, für den
Sprung per Strg+Klick.) null, oder die ID (ohne `#`), auf der die
Schreibmarke in einem `:#a,#b`-Token steht. Erkannt wird dieselbe Form wie
oben (alleinstehend angesetzt oder Kopf-Form `#auth:#db`) — nur vollständig
statt bis zur Schreibmarke, denn geklickt wird auf fertige Token. Kein
Treffer im Kommentar, im Beschreibungsteil hinter `---` und in einer URL
(die Extraktion §1 nimmt die URL zuerst — ein `:#` darin ist keins). */
const URL_RE = /https?:\/\/\S+/i;
const DEP_TOKEN_G = new RegExp(':#' + ID_CHARS + '+(?:,#' + ID_CHARS + '+)*', 'gu');
export function depIdAt(text, caret){
const sol = text.lastIndexOf('\n', caret - 1) + 1;
let eol = text.indexOf('\n', caret);
if(eol === -1) eol = text.length;
const line = text.slice(sol, eol);
const col = caret - sol;
for(const l of text.slice(0, sol).split('\n')){
if(/^\s*-{3,}\s*$/.test(l)) return null;
}
const k = line.indexOf('%%');
if(k !== -1 && col >= k) return null;
const head = k === -1 ? line : line.slice(0, k);
const u = URL_RE.exec(head);
if(u && col > u.index && col < u.index + u[0].length) return null;
/* Kopf-Form: unmittelbar hinter der Knoten-ID der Zeile (dem ersten
alleinstehenden `#`-Token, Extraktion Schritt 6) darf das Token ansetzen. */
const own = OWN_ID_RE.exec(head);
const idEnd = own ? own.index + own[0].length : -1;
DEP_TOKEN_G.lastIndex = 0;
let m;
while((m = DEP_TOKEN_G.exec(head))){
const p = m.index;
/* Alleinstehend angesetzt — `(:#a,#b)` bleibt damit Zitat (§1/D37).
`\s` wie im Parser, nicht nur Space/Tab: Auch vor einem geschützten
Leerzeichen (etwa aus einer Web-Seite kopiert) ist das Token eine
Abhängigkeit, und der Sprung muss dieselbe Zeile lesen wie der Parser. */
if(p !== idEnd && p > 0 && !/\s/.test(head[p - 1])) continue;
const end = p + m[0].length;
if(col < p || col > end) continue;
let s = p + 1; /* hinter dem `:` */
for(const part of m[0].slice(1).split(',')){
const e = s + part.length;
if(col <= e) return part.slice(1); /* ohne `#` */
s = e + 1; /* hinter dem `,` */
}
return null;
}
return null;
}
/* Die Zeile, die eine ID vergibt — bei doppelter ID die ERSTE Vergabe, wie
überall bei der Auflösung (D36/D39). null für unbekannte IDs. */
export function idLine(roots, id){
for(const n of roots){
if(n.id === id) return n.line;
const hit = idLine(n.children, id);
if(hit) return hit;
}
return null;
}
/* Passende Kandidaten: erst Präfix-Treffer, dann Teilstring-Treffer, je in
Dokumentreihenfolge; Groß-/Kleinschreibung egal (die IDs selbst bleiben,
wie sie geschrieben sind). Leeres Fragment (direkt nach `:#`) zeigt alle. */
export function matchIds(ids, fragment, exclude = []){
const ex = new Set(exclude);
const pool = ids.filter(c => !ex.has(c.id));
if(!fragment) return pool;
const f = fragment.toLowerCase();
const pre = [], sub = [];
for(const c of pool){
const lo = c.id.toLowerCase();
if(lo.startsWith(f)) pre.push(c);
else if(lo.includes(f)) sub.push(c);
}
return pre.concat(sub);
}