359 lines
8.4 KiB
TypeScript
359 lines
8.4 KiB
TypeScript
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<string, number>();
|
|
|
|
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("'", ''');
|
|
}
|