export interface ParameterLike { name?: string; ref?: string; refType?: string; resolved?: boolean; disconnected?: boolean; displayName?: string; formLabel?: string; dataType?: string; children?: ParameterLike[]; } export interface TokenRange { start: number; end: number; text: string; key: string; } export type TokenPart = | { type: 'text'; text: string; } | { type: 'token'; text: string; key: string; valid: boolean; }; export interface ParameterCandidate { name: string; resolved: boolean; disconnected?: boolean; displayName?: string; dataType?: string; } export type TokenSyntax = 'enjoy' | 'mustache'; const MUSTACHE_TOKEN_PATTERN = /\{\{\s*([^{}]+?)\s*}}/g; const ENJOY_TOKEN_PATTERN = /#\(\s*([A-Za-z_$\u4E00-\u9FA5][A-Za-z0-9_$\u4E00-\u9FA5]*(?:\.[A-Za-z_$\u4E00-\u9FA5][A-Za-z0-9_$\u4E00-\u9FA5]*)*)\s*\)/g; function getTokenPattern(syntax: TokenSyntax): RegExp { return syntax === 'enjoy' ? ENJOY_TOKEN_PATTERN : MUSTACHE_TOKEN_PATTERN; } export function formatParamToken( tokenKey: string, syntax: TokenSyntax = 'mustache', ): string { return syntax === 'enjoy' ? `#(${tokenKey})` : `{{${tokenKey}}}`; } export function normalizeTokenKey(tokenKey: string): string { return tokenKey.trim(); } export function flattenParameterNames( parameters?: ParameterLike[] | null, ): string[] { return flattenParameterCandidates(parameters).map((item) => item.name); } function isParameterResolved(parameter?: ParameterLike): boolean { if (!parameter) { return false; } if (typeof parameter.resolved === 'boolean') { return parameter.resolved; } const refType = (parameter.refType || '').trim(); if (refType === 'fixed' || refType === 'input') { return true; } const ref = (parameter.ref || '').trim(); return !!ref; } export function flattenParameterCandidates( parameters?: ParameterLike[] | null, ): ParameterCandidate[] { if (!parameters || parameters.length === 0) { return []; } const candidates: ParameterCandidate[] = []; const indexMap = new Map(); const addCandidate = ( name: string, resolved: boolean, disconnected: boolean, displayName?: string, dataType?: string, ) => { const normalized = name.trim(); if (!normalized) { return; } const exists = indexMap.get(normalized); if (exists === undefined) { indexMap.set(normalized, candidates.length); candidates.push({ name: normalized, resolved, disconnected, displayName: displayName?.trim() || normalized, dataType, }); return; } // 同名参数只要有一个可解析,就视为可解析 const existingCandidate = candidates[exists]!; if (resolved) { existingCandidate.resolved = true; existingCandidate.disconnected = false; } else if (disconnected && !existingCandidate.resolved) { existingCandidate.disconnected = true; } if (!existingCandidate.displayName && displayName?.trim()) { existingCandidate.displayName = displayName.trim(); } if (!existingCandidate.dataType && dataType) { existingCandidate.dataType = dataType; } }; const walk = ( items: ParameterLike[], parentPath = '', inheritedResolved = true, ) => { for (const item of items) { const rawName = item?.name?.trim(); if (!rawName) { continue; } const currentPath = parentPath ? `${parentPath}.${rawName}` : rawName; const currentResolved = inheritedResolved && isParameterResolved(item); const currentDisconnected = item?.disconnected === true; const displayName = item?.displayName?.trim() || item?.formLabel?.trim() || currentPath; addCandidate( currentPath, currentResolved, currentDisconnected, displayName, item?.dataType, ); if (item.children && item.children.length > 0) { walk(item.children, currentPath, currentResolved); } } }; walk(parameters); return candidates; } export function parseTokenParts( content: string, validParams: string[] = [], syntax: TokenSyntax = 'mustache', ): TokenPart[] { const source = content ?? ''; const validSet = new Set(validParams.map(normalizeTokenKey)); const parts: TokenPart[] = []; const tokenPattern = getTokenPattern(syntax); let lastIndex = 0; tokenPattern.lastIndex = 0; let match: RegExpExecArray | null = tokenPattern.exec(source); while (match) { if (match.index > lastIndex) { parts.push({ type: 'text', text: source.slice(lastIndex, match.index), }); } const rawToken = match[0]; const tokenKey = normalizeTokenKey(match[1] || ''); parts.push({ type: 'token', text: rawToken, key: tokenKey, valid: validSet.has(tokenKey), }); lastIndex = match.index + rawToken.length; match = tokenPattern.exec(source); } if (lastIndex < source.length) { parts.push({ type: 'text', text: source.slice(lastIndex), }); } if (parts.length === 0) { parts.push({ type: 'text', text: source, }); } return parts; } export function getTokenRanges( content: string, syntax: TokenSyntax = 'mustache', ): TokenRange[] { const source = content ?? ''; const ranges: TokenRange[] = []; const tokenPattern = getTokenPattern(syntax); tokenPattern.lastIndex = 0; let match: RegExpExecArray | null = tokenPattern.exec(source); while (match) { const rawToken = match[0]; ranges.push({ start: match.index, end: match.index + rawToken.length, text: rawToken, key: normalizeTokenKey(match[1] || ''), }); match = tokenPattern.exec(source); } return ranges; } export function findTokenRangeAtCursor( content: string, cursor: number, options?: { includeStart?: boolean; includeEnd?: boolean; syntax?: TokenSyntax; }, ): TokenRange | null { if (!Number.isInteger(cursor) || cursor < 0) { return null; } const includeStart = options?.includeStart ?? false; const includeEnd = options?.includeEnd ?? false; const ranges = getTokenRanges(content, options?.syntax); for (const range of ranges) { const leftValid = includeStart ? cursor >= range.start : cursor > range.start; const rightValid = includeEnd ? cursor <= range.end : cursor < range.end; if (leftValid && rightValid) { return range; } } return null; } export function findBackspaceTokenRange( content: string, cursor: number, syntax: TokenSyntax = 'mustache', ): TokenRange | null { return findTokenRangeAtCursor(content, cursor, { includeStart: false, includeEnd: true, syntax, }); } export function splitTokenDisplay( rawToken: string, normalizedKey?: string, ): { hiddenPrefix: string; visibleText: string; hiddenSuffix: string; } { const source = rawToken ?? ''; if (!source.startsWith('{{') || !source.endsWith('}}')) { return { hiddenPrefix: '', visibleText: normalizedKey || source, hiddenSuffix: '', }; } const inner = source.slice(2, -2); const visibleText = normalizeTokenKey(normalizedKey || inner); if (!visibleText) { return { hiddenPrefix: '', visibleText: source, hiddenSuffix: '', }; } const innerStart = inner.indexOf(visibleText); const leading = innerStart >= 0 ? inner.slice(0, innerStart) : ''; const trailing = innerStart >= 0 ? inner.slice(innerStart + visibleText.length) : ''; return { hiddenPrefix: `{{${leading}`, visibleText, hiddenSuffix: `${trailing}}}`, }; } export function insertTextAtCursor( content: string, insertedText: string, selectionStart?: number | null, selectionEnd?: number | null, ): { value: string; cursor: number; } { const source = content ?? ''; const start = Number.isInteger(selectionStart) ? Math.max(0, Math.min(selectionStart as number, source.length)) : source.length; const end = Number.isInteger(selectionEnd) ? Math.max(start, Math.min(selectionEnd as number, source.length)) : start; const nextValue = source.slice(0, start) + insertedText + source.slice(end); return { value: nextValue, cursor: start + insertedText.length, }; } export function escapeHtml(text: string): string { return text .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); }