Compare commits
2 Commits
40ee9f509c
...
4a8395abb6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a8395abb6 | ||
|
|
7245211b62 |
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted, nextTick } from 'vue'
|
||||||
import type { HighlighterCore } from 'shiki/core'
|
import type { HighlighterCore } from 'shiki/core'
|
||||||
|
import { getCompletions, type CompletionContext } from './completion'
|
||||||
|
|
||||||
const props = defineProps<{ modelValue: string; error: string | null }>()
|
const props = defineProps<{ modelValue: string; error: string | null }>()
|
||||||
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
||||||
@@ -9,7 +10,18 @@ const textarea = ref<HTMLTextAreaElement>()
|
|||||||
const backdrop = ref<HTMLElement>()
|
const backdrop = ref<HTMLElement>()
|
||||||
const highlighter = ref<HighlighterCore>()
|
const highlighter = ref<HighlighterCore>()
|
||||||
|
|
||||||
|
// ── Completion popup state ───────────────────────────────────────────────────
|
||||||
|
const completion = ref<CompletionContext | null>(null)
|
||||||
|
const selected = ref(0)
|
||||||
|
const popup = ref({ left: 0, top: 0 })
|
||||||
|
// Suppress the popup for one keystroke after Escape, so it doesn't pop straight
|
||||||
|
// back open while the author is still typing the word they just dismissed.
|
||||||
|
let justEscaped = false
|
||||||
|
// Pixel metrics of the (monospace, non-wrapping) textarea, measured once.
|
||||||
|
let metrics = { charWidth: 8, lineHeight: 20, padLeft: 16, padTop: 14 }
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
measure()
|
||||||
// Lazy-load Shiki as its own chunk so it stays out of the initial bundle.
|
// Lazy-load Shiki as its own chunk so it stays out of the initial bundle.
|
||||||
// Fine-grained: only the TOML grammar + one light theme, JS engine (no WASM).
|
// Fine-grained: only the TOML grammar + one light theme, JS engine (no WASM).
|
||||||
const [core, engine, toml, theme] = await Promise.all([
|
const [core, engine, toml, theme] = await Promise.all([
|
||||||
@@ -25,6 +37,93 @@ onMounted(async () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function measure() {
|
||||||
|
const el = textarea.value
|
||||||
|
if (!el) return
|
||||||
|
const cs = getComputedStyle(el)
|
||||||
|
const ctx = document.createElement('canvas').getContext('2d')!
|
||||||
|
ctx.font = `${cs.fontSize} ${cs.fontFamily}`
|
||||||
|
const lh = parseFloat(cs.lineHeight) // px value in every modern browser
|
||||||
|
metrics = {
|
||||||
|
charWidth: ctx.measureText('0000000000').width / 10,
|
||||||
|
lineHeight: Number.isNaN(lh) || lh < 4 ? parseFloat(cs.fontSize) * 1.6 : lh,
|
||||||
|
padLeft: parseFloat(cs.paddingLeft),
|
||||||
|
padTop: parseFloat(cs.paddingTop),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recompute suggestions from the live textarea value + caret. */
|
||||||
|
function refresh() {
|
||||||
|
const el = textarea.value
|
||||||
|
if (!el) return
|
||||||
|
completion.value = justEscaped ? null : getCompletions(el.value, el.selectionStart)
|
||||||
|
selected.value = 0
|
||||||
|
if (completion.value) position()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Place the popup just below the caret (monospace ⇒ column × charWidth). */
|
||||||
|
function position() {
|
||||||
|
const el = textarea.value
|
||||||
|
if (!el) return
|
||||||
|
const before = el.value.slice(0, el.selectionStart)
|
||||||
|
const lineIndex = before.split('\n').length - 1
|
||||||
|
const line = before.slice(before.lastIndexOf('\n') + 1)
|
||||||
|
let col = 0
|
||||||
|
for (const ch of line) col = ch === '\t' ? col + (2 - (col % 2)) : col + 1
|
||||||
|
popup.value = {
|
||||||
|
left: metrics.padLeft + col * metrics.charWidth - el.scrollLeft,
|
||||||
|
top: metrics.padTop + (lineIndex + 1) * metrics.lineHeight - el.scrollTop,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function accept(i: number) {
|
||||||
|
const el = textarea.value
|
||||||
|
const ctx = completion.value
|
||||||
|
if (!el || !ctx) return
|
||||||
|
const item = ctx.items[i]
|
||||||
|
const caret = ctx.from + item.insert.length
|
||||||
|
completion.value = null
|
||||||
|
emit('update:modelValue', el.value.slice(0, ctx.from) + item.insert + el.value.slice(ctx.to))
|
||||||
|
nextTick(() => {
|
||||||
|
el.setSelectionRange(caret, caret)
|
||||||
|
el.focus()
|
||||||
|
refresh() // chain, e.g. `status = ` immediately offers the enum values
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key !== 'Escape') justEscaped = false
|
||||||
|
const ctx = completion.value
|
||||||
|
if (!ctx) return
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowDown':
|
||||||
|
e.preventDefault()
|
||||||
|
selected.value = (selected.value + 1) % ctx.items.length
|
||||||
|
break
|
||||||
|
case 'ArrowUp':
|
||||||
|
e.preventDefault()
|
||||||
|
selected.value = (selected.value - 1 + ctx.items.length) % ctx.items.length
|
||||||
|
break
|
||||||
|
case 'Enter':
|
||||||
|
case 'Tab':
|
||||||
|
e.preventDefault()
|
||||||
|
accept(selected.value)
|
||||||
|
break
|
||||||
|
case 'Escape':
|
||||||
|
e.preventDefault()
|
||||||
|
completion.value = null
|
||||||
|
justEscaped = true
|
||||||
|
break
|
||||||
|
// Caret jumps would leave the popup stranded at a stale spot — close it.
|
||||||
|
case 'ArrowLeft':
|
||||||
|
case 'ArrowRight':
|
||||||
|
case 'Home':
|
||||||
|
case 'End':
|
||||||
|
completion.value = null
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const highlighted = computed(() => {
|
const highlighted = computed(() => {
|
||||||
// a trailing newline needs a trailing char so the last backdrop line keeps height
|
// a trailing newline needs a trailing char so the last backdrop line keeps height
|
||||||
const code = props.modelValue.endsWith('\n') ? props.modelValue + ' ' : props.modelValue
|
const code = props.modelValue.endsWith('\n') ? props.modelValue + ' ' : props.modelValue
|
||||||
@@ -39,12 +138,14 @@ function escapeHtml(s: string): string {
|
|||||||
|
|
||||||
function onInput(e: Event) {
|
function onInput(e: Event) {
|
||||||
emit('update:modelValue', (e.target as HTMLTextAreaElement).value)
|
emit('update:modelValue', (e.target as HTMLTextAreaElement).value)
|
||||||
|
refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncScroll() {
|
function syncScroll() {
|
||||||
if (!textarea.value || !backdrop.value) return
|
if (!textarea.value || !backdrop.value) return
|
||||||
backdrop.value.scrollTop = textarea.value.scrollTop
|
backdrop.value.scrollTop = textarea.value.scrollTop
|
||||||
backdrop.value.scrollLeft = textarea.value.scrollLeft
|
backdrop.value.scrollLeft = textarea.value.scrollLeft
|
||||||
|
if (completion.value) position()
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -63,7 +164,24 @@ function syncScroll() {
|
|||||||
aria-label="Macroplan TOML source"
|
aria-label="Macroplan TOML source"
|
||||||
@input="onInput"
|
@input="onInput"
|
||||||
@scroll="syncScroll"
|
@scroll="syncScroll"
|
||||||
|
@keydown="onKeydown"
|
||||||
|
@blur="completion = null"
|
||||||
></textarea>
|
></textarea>
|
||||||
|
<ul
|
||||||
|
v-if="completion"
|
||||||
|
class="completion"
|
||||||
|
:style="{ left: popup.left + 'px', top: popup.top + 'px' }"
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
v-for="(item, i) in completion.items"
|
||||||
|
:key="item.label"
|
||||||
|
:class="{ active: i === selected }"
|
||||||
|
@mousedown.prevent="accept(i)"
|
||||||
|
>
|
||||||
|
<span class="label">{{ item.label }}</span>
|
||||||
|
<span v-if="item.detail" class="detail">{{ item.detail }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="error" class="editor-error" role="alert">
|
<div v-if="error" class="editor-error" role="alert">
|
||||||
<span class="font-semibold">Can’t parse:</span> {{ error }}
|
<span class="font-semibold">Can’t parse:</span> {{ error }}
|
||||||
@@ -133,6 +251,42 @@ function syncScroll() {
|
|||||||
.input::selection {
|
.input::selection {
|
||||||
background: color-mix(in oklch, var(--color-primary) 28%, transparent);
|
background: color-mix(in oklch, var(--color-primary) 28%, transparent);
|
||||||
}
|
}
|
||||||
|
/* Completion popup, anchored just below the caret (see position()). */
|
||||||
|
.completion {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 2;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.25rem;
|
||||||
|
list-style: none;
|
||||||
|
min-width: 13rem;
|
||||||
|
max-width: 22rem;
|
||||||
|
max-height: 14rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--color-base-100);
|
||||||
|
border: 1px solid var(--color-base-300);
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
box-shadow: 0 8px 24px color-mix(in oklch, black 16%, transparent);
|
||||||
|
}
|
||||||
|
.completion li {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1.25rem;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.completion li.active {
|
||||||
|
background: color-mix(in oklch, var(--color-primary) 18%, transparent);
|
||||||
|
}
|
||||||
|
.completion .label {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
.completion .detail {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
color: color-mix(in oklch, var(--color-base-content) 55%, transparent);
|
||||||
|
}
|
||||||
.editor-error {
|
.editor-error {
|
||||||
padding: 0.6rem 1rem;
|
padding: 0.6rem 1rem;
|
||||||
font-size: 0.74rem;
|
font-size: 0.74rem;
|
||||||
|
|||||||
97
src/components/completion.test.ts
Normal file
97
src/components/completion.test.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { getCompletions } from './completion'
|
||||||
|
|
||||||
|
/** Split a `|`-marked string into (source, caret offset). */
|
||||||
|
function atCaret(marked: string): [string, number] {
|
||||||
|
const caret = marked.indexOf('|')
|
||||||
|
return [marked.slice(0, caret) + marked.slice(caret + 1), caret]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The labels offered at the `|` marker. */
|
||||||
|
function labelsAt(marked: string): string[] {
|
||||||
|
const [source, caret] = atCaret(marked)
|
||||||
|
return getCompletions(source, caret)?.items.map((i) => i.label) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('completion context', () => {
|
||||||
|
it('offers plan keys and block headers on an empty top-level line', () => {
|
||||||
|
expect(labelsAt('|')).toEqual(['title', 'start', 'end', '[[feature]]', '[[milestone]]'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('offers feature keys inside a [[feature]] block', () => {
|
||||||
|
expect(labelsAt('[[feature]]\n|')).toEqual([
|
||||||
|
'name',
|
||||||
|
'start',
|
||||||
|
'original',
|
||||||
|
'reestimates',
|
||||||
|
'delivered',
|
||||||
|
'status',
|
||||||
|
'learning',
|
||||||
|
'note',
|
||||||
|
'[[feature]]',
|
||||||
|
'[[milestone]]',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('offers milestone keys inside a [[milestone]] block', () => {
|
||||||
|
expect(labelsAt('[[milestone]]\n|')).toEqual([
|
||||||
|
'name',
|
||||||
|
'week',
|
||||||
|
'requires',
|
||||||
|
'[[feature]]',
|
||||||
|
'[[milestone]]',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('excludes keys already written in the current block', () => {
|
||||||
|
const labels = labelsAt('[[feature]]\nname = "X"\noriginal = 2026-06-01\n|')
|
||||||
|
expect(labels).not.toContain('name')
|
||||||
|
expect(labels).not.toContain('original')
|
||||||
|
expect(labels).toContain('start')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filters keys by the typed prefix', () => {
|
||||||
|
expect(labelsAt('[[feature]]\nst|')).toEqual(['start', 'status'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('completes a header from a leading bracket', () => {
|
||||||
|
expect(labelsAt('[[f|')).toEqual(['[[feature]]'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('suggests status values after status =', () => {
|
||||||
|
expect(labelsAt('[[feature]]\nstatus = |')).toEqual(['on-track', 'at-risk', 'off-track'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filters status values by the typed prefix, inside an open quote', () => {
|
||||||
|
expect(labelsAt('[[feature]]\nstatus = "o|')).toEqual(['on-track', 'off-track'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('suggests feature names inside a milestone requires array', () => {
|
||||||
|
const source = '[[feature]]\nname = "Payments"\n\n[[milestone]]\nrequires = ["|'
|
||||||
|
expect(labelsAt(source)).toEqual(['Payments'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not offer feature names outside a milestone block', () => {
|
||||||
|
// `requires` is meaningless at the plan level, so there is nothing to add.
|
||||||
|
expect(labelsAt('name = "Payments"\n\n[[feature]]\nrequires = ["|')).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns nothing in a value position it cannot complete (a date)', () => {
|
||||||
|
expect(getCompletions(...atCaret('[[feature]]\nstart = 2026-|'))).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('completion replace range', () => {
|
||||||
|
it('replaces the typed prefix, not the whole line', () => {
|
||||||
|
const source = '[[feature]]\nst'
|
||||||
|
const ctx = getCompletions(source, source.length)!
|
||||||
|
expect(source.slice(ctx.from, ctx.to)).toBe('st')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('replaces a partial bracketed header from the bracket', () => {
|
||||||
|
const source = '[[f'
|
||||||
|
const ctx = getCompletions(source, source.length)!
|
||||||
|
expect(source.slice(ctx.from, ctx.to)).toBe('[[f')
|
||||||
|
expect(ctx.items[0].insert).toBe('[[feature]]')
|
||||||
|
})
|
||||||
|
})
|
||||||
161
src/components/completion.ts
Normal file
161
src/components/completion.ts
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
// 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. */
|
||||||
|
export function getCompletions(source: string, caret: number): CompletionContext | null {
|
||||||
|
const lineStart = source.lastIndexOf('\n', caret - 1) + 1
|
||||||
|
const linePrefix = source.slice(lineStart, caret)
|
||||||
|
|
||||||
|
// ── value: status = "<here>" ──────────────────────────────────────────────
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
const block = currentBlock(source, lineStart)
|
||||||
|
|
||||||
|
// ── value: requires = [ "<here>" … ] (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 items = filter([...keys.filter((k) => !taken.has(k.label)), ...HEADERS], 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
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string> {
|
||||||
|
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<string>()
|
||||||
|
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<string>()
|
||||||
|
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]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user