// Schema-aware completion for the Macroplan TOML editor. // // The key / value lists below mirror the schema in `src/model/parse.ts`. // Keep them in sync when fields are added, removed, or renamed. export interface Completion { /** Text shown in the popup. */ label: string /** Text inserted in place of source[from..to]. */ insert: string /** Greyed hint shown after the label. */ detail?: string /** Used for prefix matching when it differs from `label` (e.g. headers). */ filter?: string } export interface CompletionContext { /** Replace source[from..to] with the chosen completion's `insert`. */ from: number to: number items: Completion[] } type Block = "plan" | "feature" | "milestone" const PLAN_KEYS: Completion[] = [ { label: "title", insert: "title = ", detail: "plan title" }, { label: "start", insert: "start = ", detail: "left edge — date, optional" }, { label: "end", insert: "end = ", detail: "right edge — date, optional" }, ] const FEATURE_KEYS: Completion[] = [ { label: "name", insert: "name = ", detail: "required" }, { label: "start", insert: "start = ", detail: "date, required" }, { label: "original", insert: "original = ", detail: "Original Estimate — date, required" }, { label: "reestimates", insert: "reestimates = ", detail: "list of dates" }, { label: "delivered", insert: "delivered = ", detail: "date" }, { label: "status", insert: "status = ", detail: "on-track | at-risk | off-track" }, { label: "learning", insert: "learning = ", detail: "string" }, { label: "note", insert: "note = ", detail: "string" }, ] const MILESTONE_KEYS: Completion[] = [ { label: "name", insert: "name = ", detail: "required" }, { label: "week", insert: "week = ", detail: "date, required" }, { label: "requires", insert: "requires = ", detail: "list of feature names" }, ] const HEADERS: Completion[] = [ { label: "[[feature]]", insert: "[[feature]]", detail: "new feature", filter: "feature" }, { label: "[[milestone]]", insert: "[[milestone]]", detail: "new milestone", filter: "milestone" }, ] const STATUSES = ["on-track", "at-risk", "off-track"] // keep in sync with parse.ts /** What to suggest at `caret` in `source`, or null when nothing applies. * `today` (yyyy-mm-dd) seeds the date suggestions; injectable for tests. */ export function getCompletions( source: string, caret: number, today: string = todayYmd(), ): CompletionContext | null { const lineStart = source.lastIndexOf("\n", caret - 1) + 1 const linePrefix = source.slice(lineStart, caret) // ── value: status = "" — while typing, not once the quote is closed ── const status = /^(\s*status\s*=\s*)"?([A-Za-z-]*)$/.exec(linePrefix) if (status) { const items = filter( STATUSES.map((s) => ({ label: s, insert: `"${s}"` })), status[2], ) return result(lineStart + status[1].length, caret, items) } // ── value: a date field — start = , week = , … ──────────────── // Only on an empty value, so it never fights a date already being typed // (the chained refresh after picking the key surfaces these immediately). if (/^\s*(?:start|end|original|delivered|week)\s*=\s*$/.test(linePrefix)) { return result(caret, caret, dateItems(today)) } // ── value: reestimates = [ … ] — a date for a fresh array element ── const inReestimates = /reestimates\s*=\s*\[[^\]]*$/.test(linePrefix) if (inReestimates && /[[,]\s*$/.test(linePrefix)) { return result(caret, caret, dateItems(today)) } const block = currentBlock(source, lineStart) // ── value: requires = [ "" … ] (milestones only) ─────────────────── if (block === "milestone" && /requires\s*=\s*\[[^\]]*$/.test(linePrefix)) { const token = /("?)([^",[\]]*)$/.exec(linePrefix)! const items = filter( featureNames(source).map((n) => ({ label: n, insert: `"${n}"` })), token[2], ) return result(caret - token[1].length - token[2].length, caret, items) } // ── key / header position: only indentation then an optional word ───────── const key = /^(\s*)(\[*)([A-Za-z]*)$/.exec(linePrefix) if (key) { const [, indent, brackets, word] = key if (brackets) { return result(lineStart + indent.length, caret, filter(HEADERS, word)) } const keys = block === "feature" ? FEATURE_KEYS : block === "milestone" ? MILESTONE_KEYS : PLAN_KEYS const taken = presentKeys(source, lineStart, block) const remaining = keys.filter((k) => !taken.has(k.label)) // Volunteer a new block header only once there's a blank line above: a new // [[…]] wants a gap before it, so offering one right under the last property // (or straight after the header line) pops the menu up too eagerly. Typing a // bare `[` still summons headers anywhere (handled above). With all keys set // and no gap, `remaining` is empty ⇒ result() returns null ⇒ no popup. const items = filter( blankLineAbove(source, lineStart) ? [...remaining, ...HEADERS] : remaining, word, ) return result(caret - word.length, caret, items) } return null } function result(from: number, to: number, items: Completion[]): CompletionContext | null { return items.length ? { from, to, items } : null } /** Is the line directly above the caret's line blank (empty or whitespace only)? * Gates header suggestions to the "two new lines" rule — see the key branch. */ function blankLineAbove(source: string, lineStart: number): boolean { if (lineStart === 0) return false // top of the document — nothing above const prevStart = source.lastIndexOf("\n", lineStart - 2) + 1 return /^[ \t]*$/.test(source.slice(prevStart, lineStart - 1)) } /** Today as yyyy-mm-dd in the author's local zone — the calendar day they see, * not UTC's (which slips a day near midnight). Injectable keeps tests stable. */ function todayYmd(): string { const d = new Date() const pad = (n: number) => String(n).padStart(2, "0") return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` } /** Date suggestions: the full date (easy to tweak the day/month in place) and * the current-year prefix (accept it, then type the month-day). */ function dateItems(today: string): Completion[] { return [ { label: today, insert: today, detail: "today" }, { label: today.slice(0, 5), insert: today.slice(0, 5), detail: "this year" }, ] } function filter(items: Completion[], word: string): Completion[] { if (!word) return items const w = word.toLowerCase() return items.filter((i) => (i.filter ?? i.label).toLowerCase().startsWith(w)) } const HEADER_RE = /^[ \t]*\[\[(feature|milestone)\]\]/gm /** The block owning the line at `lineStart` — 'plan' before the first header. */ function currentBlock(source: string, lineStart: number): Block { let block: Block = "plan" HEADER_RE.lastIndex = 0 let m: RegExpExecArray | null while ((m = HEADER_RE.exec(source)) !== null && m.index < lineStart) { block = m[1] as Block } return block } /** Keys already written in the current block, so we don't offer duplicates. */ function presentKeys(source: string, lineStart: number, block: Block): Set { const headers: number[] = [] HEADER_RE.lastIndex = 0 let m: RegExpExecArray | null while ((m = HEADER_RE.exec(source)) !== null) headers.push(m.index) let start = 0 let end = source.length if (block === "plan") { end = headers[0] ?? source.length } else { for (let i = 0; i < headers.length; i++) { if (headers[i] < lineStart) { start = headers[i] end = headers[i + 1] ?? source.length } else break } } const taken = new Set() const region = source.slice(start, end) const keyLine = /^[ \t]*([A-Za-z][\w-]*)\s*=/gm while ((m = keyLine.exec(region)) !== null) taken.add(m[1]) return taken } /** Names declared in `[[feature]]` blocks — the valid targets for `requires`. */ function featureNames(source: string): string[] { const re = /^[ \t]*\[\[(feature|milestone)\]\]|^[ \t]*name\s*=\s*"([^"]*)"/gm const names = new Set() let block: Block = "plan" let m: RegExpExecArray | null while ((m = re.exec(source)) !== null) { if (m[1]) block = m[1] as Block else if (m[2] && block === "feature") names.add(m[2]) } return [...names] }