import { CompletionContext, CompletionResult } from '@codemirror/autocomplete' import type { EditorState } from '@codemirror/state' const NUNJUCKS_KEYWORDS = [ 'if', 'endif', 'elif', 'else', 'for', 'endfor', 'in', 'and', 'or', 'not', 'true', 'false', 'none', 'macro', 'endmacro', 'set', 'endset', 'block', 'endblock', 'extends', 'include', 'import', 'with', 'endwith', 'filter', 'endfilter', 'raw', 'endraw', ] const NUNJUCKS_FILTERS = [ 'default', 'length', 'upper', 'lower', 'title', 'trim', 'join', 'replace', 'first', 'last', 'round', 'int', 'float', 'string', 'list', 'sort', 'groupby', 'trim', 'escape', 'safe', 'striptags', 'capitalize', 'reverse', 'batch', 'slice', ] /** Get line text (CodeMirror 6: doc.line(n) is 1-based) */ function getLineText(state: EditorState, lineNo0Based: number): string { return state.doc.line(lineNo0Based + 1).text } /** Detect if position is inside {{ or {% from the start of the line */ function insideNunjucks(state: EditorState, lineNo0Based: number, posInLine: number): boolean { const line = getLineText(state, lineNo0Based) const before = line.slice(0, posInLine) const openVar = before.lastIndexOf('{{') const openTag = before.lastIndexOf('{%') const closeVar = before.lastIndexOf('}}') const closeTag = before.lastIndexOf('%}') if (openVar > -1 && (closeVar === -1 || closeVar < openVar)) return true if (openTag > -1 && (closeTag === -1 || closeTag < openTag)) return true return false } /** Get the word fragment before the cursor for matching */ function wordBefore(state: EditorState, lineNo0Based: number, posInLine: number): string { const line = getLineText(state, lineNo0Based) let start = posInLine while (start > 0 && /[\w.-]/.test(line[start - 1])) start -= 1 return line.slice(start, posInLine) } export function nunjucksCompletionSource( variableIds: string[], configTitles?: string[], functionIds?: string[], ): (context: CompletionContext) => CompletionResult | null { return (context: CompletionContext) => { const { state, pos } = context const line = state.doc.lineAt(pos) if (!insideNunjucks(state, line.number - 1, pos - line.from)) return null const word = wordBefore(state, line.number - 1, pos - line.from) const from = pos - word.length const options: { label: string; type?: string; info?: string }[] = [] for (const id of variableIds) { if (!word || id.toLowerCase().startsWith(word.toLowerCase())) { options.push({ label: id, type: 'variable', info: 'Variable' }) } } for (const id of functionIds ?? []) { if (!word || id.toLowerCase().startsWith(word.toLowerCase())) { options.push({ label: id, type: 'function', info: "Filter: {{ '' | " + id + " }}" }) } } for (const kw of NUNJUCKS_KEYWORDS) { if (!word || kw.startsWith(word.toLowerCase())) { options.push({ label: kw, type: 'keyword', info: 'Nunjucks keyword' }) } } for (const f of NUNJUCKS_FILTERS) { if (!word || f.startsWith(word.toLowerCase())) { options.push({ label: `${f}`, type: 'function', info: `Filter: ${f}` }) } } if (configTitles?.length) { for (const t of configTitles) { if (!word || t.toLowerCase().startsWith(word.toLowerCase())) { options.push({ label: t, type: 'variable', info: 'Config' }) } } } if (options.length === 0) return null return { from, options: options.slice(0, 50), validFor: /^[\w.-]*$/, } } }