/* 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); }