Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
618 lines
22 KiB
TypeScript
618 lines
22 KiB
TypeScript
import { RegExpParser, type AST } from '@eslint-community/regexpp';
|
|
import { RE2JS } from 're2js';
|
|
|
|
export const MAX_PLUGIN_COMPLETION_ITEMS = 100;
|
|
export const MAX_PLUGIN_DECORATION_RULES = 64;
|
|
export const MAX_ACTIVE_PLUGIN_DECORATION_RULES = 16;
|
|
export const MAX_ACTIVE_PLUGIN_DECORATION_PATTERNS = 32;
|
|
export const MAX_PLUGIN_TERMINAL_RANGES = 64;
|
|
export const MAX_PLUGIN_PROMPT_ANNOTATIONS = 8;
|
|
export const MAX_PLUGIN_BACKGROUND_LAYERS = 4;
|
|
export const DEFAULT_PLUGIN_BACKGROUND_OPACITY = 0.15;
|
|
|
|
const pluginPatternParser = new RegExpParser({ ecmaVersion: 2025 });
|
|
const MAX_PLUGIN_PATTERN_QUANTIFIERS = 32;
|
|
|
|
export interface PluginTerminalCompletionItem {
|
|
readonly text: string;
|
|
readonly displayText: string;
|
|
readonly description?: string;
|
|
readonly score: number;
|
|
readonly providerId: string;
|
|
}
|
|
|
|
export interface PluginTerminalDecorationRule {
|
|
readonly id: string;
|
|
readonly label: string;
|
|
readonly patterns: readonly string[];
|
|
readonly color: string;
|
|
readonly enabled: true;
|
|
readonly providerId: string;
|
|
}
|
|
|
|
export interface PluginTerminalTextRange {
|
|
readonly start: number;
|
|
readonly length: number;
|
|
}
|
|
|
|
export interface PluginTerminalLink extends PluginTerminalTextRange {
|
|
readonly uri: string;
|
|
readonly label?: string;
|
|
readonly providerId: string;
|
|
}
|
|
|
|
export interface PluginTerminalHover extends PluginTerminalTextRange {
|
|
readonly contents: string;
|
|
readonly providerId: string;
|
|
}
|
|
|
|
export interface PluginTerminalOutputMatch extends PluginTerminalTextRange {
|
|
readonly lineId: string;
|
|
readonly label: string;
|
|
readonly severity: 'info' | 'warning' | 'error' | 'success';
|
|
readonly color?: string;
|
|
readonly providerId: string;
|
|
}
|
|
|
|
export interface PluginTerminalAnnotation {
|
|
readonly text: string;
|
|
readonly color?: string;
|
|
readonly description?: string;
|
|
readonly providerId: string;
|
|
}
|
|
|
|
export interface PluginTerminalSemanticResult {
|
|
readonly classification?: string;
|
|
readonly description?: string;
|
|
readonly destructive: boolean;
|
|
readonly idempotent: boolean;
|
|
readonly annotations: readonly PluginTerminalAnnotation[];
|
|
}
|
|
|
|
export interface PluginTerminalBackgroundLayer {
|
|
readonly id: string;
|
|
readonly color: string;
|
|
readonly opacity: number;
|
|
readonly providerId: string;
|
|
}
|
|
|
|
export const PLUGIN_TERMINAL_THEME_COLOR_KEYS = Object.freeze([
|
|
'background', 'foreground', 'cursor', 'selection',
|
|
'black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white',
|
|
'brightBlack', 'brightRed', 'brightGreen', 'brightYellow',
|
|
'brightBlue', 'brightMagenta', 'brightCyan', 'brightWhite',
|
|
] as const);
|
|
|
|
export type PluginTerminalThemeColor = typeof PLUGIN_TERMINAL_THEME_COLOR_KEYS[number];
|
|
export type PluginTerminalThemeColors = Readonly<Partial<Record<PluginTerminalThemeColor, string>>>;
|
|
|
|
export function normalizePluginThemeResult(
|
|
_providerId: string,
|
|
value: unknown,
|
|
): PluginTerminalThemeColors {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return Object.freeze({});
|
|
const colors = (value as { colors?: unknown }).colors;
|
|
if (!colors || typeof colors !== 'object' || Array.isArray(colors)) return Object.freeze({});
|
|
const normalized: Partial<Record<PluginTerminalThemeColor, string>> = {};
|
|
for (const key of PLUGIN_TERMINAL_THEME_COLOR_KEYS) {
|
|
const color = (colors as Record<string, unknown>)[key];
|
|
if (typeof color === 'string' && /^#[0-9A-Fa-f]{6}(?:[0-9A-Fa-f]{2})?$/u.test(color)) {
|
|
normalized[key] = color;
|
|
}
|
|
}
|
|
return Object.freeze(normalized);
|
|
}
|
|
|
|
function hasUnsafeTextControl(value: string): boolean {
|
|
for (const character of value) {
|
|
const codePoint = character.codePointAt(0) ?? 0;
|
|
if (codePoint <= 0x1f
|
|
|| (codePoint >= 0x7f && codePoint <= 0x9f)
|
|
|| (codePoint >= 0x202a && codePoint <= 0x202e)
|
|
|| (codePoint >= 0x2066 && codePoint <= 0x2069)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function boundedString(value: unknown, maximum: number, allowEmpty = false): string | null {
|
|
if (typeof value !== 'string'
|
|
|| value.length > maximum
|
|
|| (!allowEmpty && value.length < 1)
|
|
|| hasUnsafeTextControl(value)) {
|
|
return null;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function finiteScore(value: unknown): number {
|
|
return typeof value === 'number' && Number.isFinite(value)
|
|
? Math.max(-1_000_000, Math.min(1_000_000, value))
|
|
: 0;
|
|
}
|
|
|
|
function freezeArray<T extends object>(values: T[]): readonly Readonly<T>[] {
|
|
for (const value of values) Object.freeze(value);
|
|
return Object.freeze(values);
|
|
}
|
|
|
|
function normalizeTextRange(
|
|
value: Record<string, unknown>,
|
|
lineLength: number,
|
|
): PluginTerminalTextRange | null {
|
|
const start = value.start;
|
|
const length = value.length;
|
|
if (!Number.isInteger(start) || !Number.isInteger(length)) return null;
|
|
const normalizedStart = start as number;
|
|
const normalizedLength = length as number;
|
|
if (normalizedStart < 0
|
|
|| normalizedLength < 1
|
|
|| normalizedStart > lineLength
|
|
|| normalizedLength > lineLength - normalizedStart) return null;
|
|
return { start: normalizedStart, length: normalizedLength };
|
|
}
|
|
|
|
function normalizeColor(value: unknown): string | undefined | null {
|
|
if (value == null) return undefined;
|
|
const color = boundedString(value, 9);
|
|
return color && /^#[0-9A-Fa-f]{6}(?:[0-9A-Fa-f]{2})?$/u.test(color) ? color : null;
|
|
}
|
|
|
|
function normalizeAnnotation(
|
|
providerId: string,
|
|
value: unknown,
|
|
): PluginTerminalAnnotation | null {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
const source = value as Record<string, unknown>;
|
|
const text = boundedString(source.text, 512);
|
|
const color = normalizeColor(source.color);
|
|
if (!text || color === null) return null;
|
|
return { text, ...(color === undefined ? {} : { color }), providerId };
|
|
}
|
|
|
|
export function normalizePluginLinkResult(
|
|
providerId: string,
|
|
value: unknown,
|
|
lineLength: number,
|
|
): readonly PluginTerminalLink[] {
|
|
const source = value && typeof value === 'object' && !Array.isArray(value)
|
|
? value as { links?: unknown }
|
|
: null;
|
|
if (!Number.isInteger(lineLength) || lineLength < 0 || lineLength > 8_192
|
|
|| !Array.isArray(source?.links) || source.links.length > MAX_PLUGIN_TERMINAL_RANGES) {
|
|
return Object.freeze([]);
|
|
}
|
|
const links: PluginTerminalLink[] = [];
|
|
for (const candidate of source.links) {
|
|
if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) continue;
|
|
const item = candidate as Record<string, unknown>;
|
|
const range = normalizeTextRange(item, lineLength);
|
|
const uri = boundedString(item.uri, 2_048);
|
|
const label = item.label == null ? undefined : boundedString(item.label, 256, true);
|
|
if (!range || !uri || (item.label != null && label == null)) continue;
|
|
try {
|
|
const parsed = new URL(uri);
|
|
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') continue;
|
|
if (parsed.username || parsed.password) continue;
|
|
} catch {
|
|
continue;
|
|
}
|
|
links.push({ ...range, uri, ...(label === undefined ? {} : { label }), providerId });
|
|
}
|
|
return freezeArray(links);
|
|
}
|
|
|
|
export function normalizePluginHoverResult(
|
|
providerId: string,
|
|
value: unknown,
|
|
lineLength: number,
|
|
): readonly PluginTerminalHover[] {
|
|
const source = value && typeof value === 'object' && !Array.isArray(value)
|
|
? value as { hovers?: unknown }
|
|
: null;
|
|
if (!Number.isInteger(lineLength) || lineLength < 0 || lineLength > 8_192
|
|
|| !Array.isArray(source?.hovers) || source.hovers.length > MAX_PLUGIN_TERMINAL_RANGES) {
|
|
return Object.freeze([]);
|
|
}
|
|
const hovers: PluginTerminalHover[] = [];
|
|
for (const candidate of source.hovers) {
|
|
if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) continue;
|
|
const item = candidate as Record<string, unknown>;
|
|
const range = normalizeTextRange(item, lineLength);
|
|
const contents = boundedString(item.contents, 2_048);
|
|
if (!range || !contents) continue;
|
|
hovers.push({ ...range, contents, providerId });
|
|
}
|
|
return freezeArray(hovers);
|
|
}
|
|
|
|
export function normalizePluginMatcherResult(
|
|
providerId: string,
|
|
value: unknown,
|
|
lineLengths: ReadonlyMap<string, number>,
|
|
): readonly PluginTerminalOutputMatch[] {
|
|
const source = value && typeof value === 'object' && !Array.isArray(value)
|
|
? value as { matches?: unknown }
|
|
: null;
|
|
if (!Array.isArray(source?.matches) || source.matches.length > MAX_PLUGIN_TERMINAL_RANGES) {
|
|
return Object.freeze([]);
|
|
}
|
|
const matches: PluginTerminalOutputMatch[] = [];
|
|
for (const candidate of source.matches) {
|
|
if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) continue;
|
|
const item = candidate as Record<string, unknown>;
|
|
const lineId = boundedString(item.lineId, 64);
|
|
const lineLength = lineId ? lineLengths.get(lineId) : undefined;
|
|
if (lineLength === undefined) continue;
|
|
const range = normalizeTextRange(item, lineLength);
|
|
const label = boundedString(item.label, 256);
|
|
const severity = item.severity ?? 'info';
|
|
const color = normalizeColor(item.color);
|
|
if (!range || !label || color === null
|
|
|| !['info', 'warning', 'error', 'success'].includes(String(severity))) continue;
|
|
matches.push({
|
|
...range,
|
|
lineId,
|
|
label,
|
|
severity: severity as PluginTerminalOutputMatch['severity'],
|
|
...(color === undefined ? {} : { color }),
|
|
providerId,
|
|
});
|
|
}
|
|
return freezeArray(matches);
|
|
}
|
|
|
|
export function normalizePluginSemanticResult(
|
|
providerId: string,
|
|
value: unknown,
|
|
): Readonly<PluginTerminalSemanticResult> {
|
|
const source = value && typeof value === 'object' && !Array.isArray(value)
|
|
? value as Record<string, unknown>
|
|
: {};
|
|
const classification = source.classification == null
|
|
? undefined
|
|
: boundedString(source.classification, 128);
|
|
const description = source.description == null
|
|
? undefined
|
|
: boundedString(source.description, 1_024, true);
|
|
const annotationSource = Array.isArray(source.annotations)
|
|
&& source.annotations.length <= MAX_PLUGIN_PROMPT_ANNOTATIONS
|
|
? source.annotations
|
|
: [];
|
|
const annotations = annotationSource
|
|
.map((item) => normalizeAnnotation(providerId, item))
|
|
.filter((item): item is PluginTerminalAnnotation => item !== null);
|
|
return Object.freeze({
|
|
...(classification ? { classification } : {}),
|
|
...(description != null ? { description } : {}),
|
|
destructive: source.destructive === true,
|
|
idempotent: source.idempotent === true,
|
|
annotations: freezeArray(annotations),
|
|
});
|
|
}
|
|
|
|
export function normalizePluginPromptResult(
|
|
providerId: string,
|
|
value: unknown,
|
|
): readonly PluginTerminalAnnotation[] {
|
|
const source = value && typeof value === 'object' && !Array.isArray(value)
|
|
? value as { annotations?: unknown }
|
|
: null;
|
|
if (!Array.isArray(source?.annotations)
|
|
|| source.annotations.length > MAX_PLUGIN_PROMPT_ANNOTATIONS) return Object.freeze([]);
|
|
return freezeArray(source.annotations
|
|
.map((item) => normalizeAnnotation(providerId, item))
|
|
.filter((item): item is PluginTerminalAnnotation => item !== null));
|
|
}
|
|
|
|
export function normalizePluginBackgroundResult(
|
|
providerId: string,
|
|
value: unknown,
|
|
): readonly PluginTerminalBackgroundLayer[] {
|
|
const source = value && typeof value === 'object' && !Array.isArray(value)
|
|
? value as { layers?: unknown }
|
|
: null;
|
|
if (!Array.isArray(source?.layers)
|
|
|| source.layers.length > MAX_PLUGIN_BACKGROUND_LAYERS) return Object.freeze([]);
|
|
const layers: PluginTerminalBackgroundLayer[] = [];
|
|
const seen = new Set<string>();
|
|
for (const candidate of source.layers) {
|
|
if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) continue;
|
|
const item = candidate as Record<string, unknown>;
|
|
const localId = boundedString(item.id, 128);
|
|
const color = normalizeColor(item.color);
|
|
const opacity = item.opacity == null ? DEFAULT_PLUGIN_BACKGROUND_OPACITY : item.opacity;
|
|
if (!localId || !color || typeof opacity !== 'number' || !Number.isFinite(opacity)
|
|
|| opacity < 0 || opacity > 0.35) continue;
|
|
const id = `${providerId}:${localId}`;
|
|
if (seen.has(id)) continue;
|
|
seen.add(id);
|
|
layers.push({ id, color, opacity, providerId });
|
|
}
|
|
return freezeArray(layers);
|
|
}
|
|
|
|
export function normalizePluginBackgroundRefreshAfterMs(value: unknown): number | undefined {
|
|
const source = value && typeof value === 'object' && !Array.isArray(value)
|
|
? value as { refreshAfterMs?: unknown }
|
|
: null;
|
|
return Number.isInteger(source?.refreshAfterMs)
|
|
&& (source?.refreshAfterMs as number) >= 250
|
|
&& (source?.refreshAfterMs as number) <= 60_000
|
|
? source?.refreshAfterMs as number
|
|
: undefined;
|
|
}
|
|
|
|
export function normalizePluginCompletionResult(
|
|
providerId: string,
|
|
value: unknown,
|
|
): readonly PluginTerminalCompletionItem[] {
|
|
const source = value && typeof value === 'object' && !Array.isArray(value)
|
|
? value as { items?: unknown }
|
|
: null;
|
|
if (!Array.isArray(source?.items) || source.items.length > MAX_PLUGIN_COMPLETION_ITEMS) return Object.freeze([]);
|
|
const items: PluginTerminalCompletionItem[] = [];
|
|
for (const candidate of source.items) {
|
|
if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) continue;
|
|
const item = candidate as Record<string, unknown>;
|
|
const text = boundedString(item.text, 4_096);
|
|
if (!text) continue;
|
|
// Plugin labels must never conceal the bytes that accepting the completion
|
|
// inserts. This is particularly important for serial sessions, where the
|
|
// previewless Enter path can execute a selected suggestion immediately.
|
|
const displayText = text;
|
|
const description = item.description == null ? undefined : boundedString(item.description, 2_048, true);
|
|
if (item.description != null && description == null) continue;
|
|
items.push({
|
|
text,
|
|
displayText,
|
|
...(description === undefined ? {} : { description }),
|
|
score: finiteScore(item.score),
|
|
providerId,
|
|
});
|
|
}
|
|
return freezeArray(items);
|
|
}
|
|
|
|
export function mergePluginCompletionItems(
|
|
groups: readonly (readonly PluginTerminalCompletionItem[])[],
|
|
maximum: number,
|
|
): readonly PluginTerminalCompletionItem[] {
|
|
const seen = new Set<string>();
|
|
const merged = groups.flatMap((group, providerRank) => group.map((item, itemRank) => ({
|
|
item,
|
|
providerRank,
|
|
itemRank,
|
|
})));
|
|
merged.sort((left, right) => right.item.score - left.item.score
|
|
|| left.providerRank - right.providerRank
|
|
|| left.itemRank - right.itemRank
|
|
|| left.item.text.localeCompare(right.item.text));
|
|
const result: PluginTerminalCompletionItem[] = [];
|
|
for (const { item } of merged) {
|
|
if (seen.has(item.text)) continue;
|
|
seen.add(item.text);
|
|
result.push(item);
|
|
if (result.length >= maximum) break;
|
|
}
|
|
return freezeArray(result);
|
|
}
|
|
|
|
interface RegexCharacterDomain {
|
|
readonly any: boolean;
|
|
readonly ascii: ReadonlySet<number>;
|
|
readonly nonAscii: boolean;
|
|
}
|
|
|
|
const anyCharacterDomain = (): RegexCharacterDomain => ({
|
|
any: true,
|
|
ascii: new Set(),
|
|
nonAscii: true,
|
|
});
|
|
|
|
function characterDomain(value: number): RegexCharacterDomain {
|
|
if (value > 0x7f) return { any: false, ascii: new Set(), nonAscii: true };
|
|
const ascii = new Set([value]);
|
|
addAsciiIgnoreCaseEquivalents(ascii);
|
|
return { any: false, ascii, nonAscii: false };
|
|
}
|
|
|
|
function addAsciiRange(target: Set<number>, minimum: number, maximum: number): void {
|
|
for (let value = Math.max(0, minimum); value <= Math.min(0x7f, maximum); value += 1) {
|
|
target.add(value);
|
|
}
|
|
}
|
|
|
|
function addAsciiIgnoreCaseEquivalents(target: Set<number>): void {
|
|
for (const value of [...target]) {
|
|
if (value >= 0x41 && value <= 0x5a) target.add(value + 0x20);
|
|
else if (value >= 0x61 && value <= 0x7a) target.add(value - 0x20);
|
|
}
|
|
}
|
|
|
|
function characterSetDomain(element: AST.CharacterSet): RegexCharacterDomain {
|
|
if (element.kind === 'any' || element.kind === 'property' || element.negate) {
|
|
return anyCharacterDomain();
|
|
}
|
|
const ascii = new Set<number>();
|
|
if (element.kind === 'digit') {
|
|
addAsciiRange(ascii, 0x30, 0x39);
|
|
} else if (element.kind === 'word') {
|
|
addAsciiRange(ascii, 0x30, 0x39);
|
|
addAsciiRange(ascii, 0x41, 0x5a);
|
|
addAsciiRange(ascii, 0x61, 0x7a);
|
|
ascii.add(0x5f);
|
|
} else {
|
|
for (const value of [0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x20]) ascii.add(value);
|
|
}
|
|
addAsciiIgnoreCaseEquivalents(ascii);
|
|
return { any: false, ascii, nonAscii: element.kind !== 'digit' };
|
|
}
|
|
|
|
function characterClassDomain(element: AST.CharacterClass): RegexCharacterDomain {
|
|
if (element.negate || element.unicodeSets) return anyCharacterDomain();
|
|
const ascii = new Set<number>();
|
|
let nonAscii = false;
|
|
for (const member of element.elements) {
|
|
if (member.type === 'Character') {
|
|
if (member.value <= 0x7f) ascii.add(member.value);
|
|
else nonAscii = true;
|
|
continue;
|
|
}
|
|
if (member.type === 'CharacterClassRange') {
|
|
addAsciiRange(ascii, member.min.value, member.max.value);
|
|
if (member.max.value > 0x7f) nonAscii = true;
|
|
continue;
|
|
}
|
|
if (member.type === 'CharacterSet') {
|
|
const domain = characterSetDomain(member);
|
|
if (domain.any) return domain;
|
|
for (const value of domain.ascii) ascii.add(value);
|
|
nonAscii ||= domain.nonAscii;
|
|
continue;
|
|
}
|
|
return anyCharacterDomain();
|
|
}
|
|
addAsciiIgnoreCaseEquivalents(ascii);
|
|
return { any: false, ascii, nonAscii };
|
|
}
|
|
|
|
function quantifiedAtomDomain(element: AST.QuantifiableElement): RegexCharacterDomain | null {
|
|
if (element.type === 'Character') return characterDomain(element.value);
|
|
if (element.type === 'CharacterSet') return characterSetDomain(element);
|
|
if (element.type === 'CharacterClass') return characterClassDomain(element);
|
|
return null;
|
|
}
|
|
|
|
function domainsOverlap(left: RegexCharacterDomain, right: RegexCharacterDomain): boolean {
|
|
if (left.any || right.any) return true;
|
|
if (left.nonAscii && right.nonAscii) return true;
|
|
for (const value of left.ascii) {
|
|
if (right.ascii.has(value)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function elementCanBeEmpty(element: AST.Element): boolean {
|
|
if (element.type === 'Assertion') return true;
|
|
if (element.type === 'Quantifier') return element.min === 0;
|
|
if (element.type === 'Group' || element.type === 'CapturingGroup') {
|
|
return element.alternatives.some((alternative) => alternative.elements.every(elementCanBeEmpty));
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function hasAmbiguousQuantifiedAtoms(pattern: AST.Pattern): boolean {
|
|
let quantifierCount = 0;
|
|
const inspectAlternatives = (alternatives: readonly AST.Alternative[]): boolean => {
|
|
for (const alternative of alternatives) {
|
|
const { elements } = alternative;
|
|
for (const element of elements) {
|
|
if (element.type === 'Group' || element.type === 'CapturingGroup') {
|
|
if (inspectAlternatives(element.alternatives)) return true;
|
|
}
|
|
if (element.type !== 'Quantifier') continue;
|
|
quantifierCount += 1;
|
|
if (quantifierCount > MAX_PLUGIN_PATTERN_QUANTIFIERS) return true;
|
|
// Quantified groups, assertions, and backreferences are deliberately
|
|
// outside the accepted linear-time subset.
|
|
if (!quantifiedAtomDomain(element.element)) return true;
|
|
}
|
|
for (let leftIndex = 0; leftIndex < elements.length; leftIndex += 1) {
|
|
const left = elements[leftIndex];
|
|
if (left.type !== 'Quantifier' || left.max <= 1) continue;
|
|
const leftDomain = quantifiedAtomDomain(left.element);
|
|
if (!leftDomain) return true;
|
|
for (let rightIndex = leftIndex + 1; rightIndex < elements.length; rightIndex += 1) {
|
|
const right = elements[rightIndex];
|
|
if (right.type === 'Quantifier' && right.max > 1) {
|
|
const rightDomain = quantifiedAtomDomain(right.element);
|
|
if (!rightDomain || domainsOverlap(leftDomain, rightDomain)) return true;
|
|
}
|
|
if (!elementCanBeEmpty(right)) break;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
return inspectAlternatives(pattern.alternatives);
|
|
}
|
|
|
|
export function isSafePluginDecorationPattern(source: string): boolean {
|
|
if (!(source.length > 0
|
|
&& source.length <= 512
|
|
&& !/\(\?/u.test(source)
|
|
&& !/\\(?:[1-9]|k<)/u.test(source)
|
|
&& !/\)(?:[*+?]|\{\d+(?:,\d*)?\})/u.test(source))) return false;
|
|
try {
|
|
const pattern = pluginPatternParser.parsePattern(source, 0, source.length, {
|
|
unicode: false,
|
|
unicodeSets: false,
|
|
});
|
|
if (pattern.alternatives.some((alternative) => alternative.elements.every(elementCanBeEmpty))) {
|
|
return false;
|
|
}
|
|
if (hasAmbiguousQuantifiedAtoms(pattern)) return false;
|
|
void RE2JS.compile(source, RE2JS.CASE_INSENSITIVE);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function normalizePluginDecorationResult(
|
|
providerId: string,
|
|
value: unknown,
|
|
): readonly PluginTerminalDecorationRule[] {
|
|
const source = value && typeof value === 'object' && !Array.isArray(value)
|
|
? value as { rules?: unknown }
|
|
: null;
|
|
if (!Array.isArray(source?.rules) || source.rules.length > MAX_PLUGIN_DECORATION_RULES) return Object.freeze([]);
|
|
const result: PluginTerminalDecorationRule[] = [];
|
|
const seen = new Set<string>();
|
|
for (const candidate of source.rules) {
|
|
if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) continue;
|
|
const rule = candidate as Record<string, unknown>;
|
|
const localId = boundedString(rule.id, 128);
|
|
const label = boundedString(rule.label, 256);
|
|
const color = boundedString(rule.color, 32);
|
|
if (!localId || !label || !color || !/^#[0-9A-Fa-f]{6}(?:[0-9A-Fa-f]{2})?$/u.test(color)) continue;
|
|
if (!Array.isArray(rule.patterns) || rule.patterns.length < 1 || rule.patterns.length > 16) continue;
|
|
const patterns = rule.patterns.filter((pattern): pattern is string => (
|
|
typeof pattern === 'string' && isSafePluginDecorationPattern(pattern)
|
|
));
|
|
if (patterns.length !== rule.patterns.length) continue;
|
|
const id = `${providerId}:${localId}`;
|
|
if (seen.has(id)) continue;
|
|
seen.add(id);
|
|
result.push({
|
|
id,
|
|
label,
|
|
patterns: Object.freeze([...patterns]),
|
|
color,
|
|
enabled: true,
|
|
providerId,
|
|
});
|
|
}
|
|
return freezeArray(result);
|
|
}
|
|
|
|
export function mergePluginDecorationRules(
|
|
groups: readonly (readonly PluginTerminalDecorationRule[])[],
|
|
maximum = MAX_ACTIVE_PLUGIN_DECORATION_RULES,
|
|
): readonly PluginTerminalDecorationRule[] {
|
|
const result: PluginTerminalDecorationRule[] = [];
|
|
const seen = new Set<string>();
|
|
let patternCount = 0;
|
|
for (const group of groups) {
|
|
for (const rule of group) {
|
|
if (seen.has(rule.id)) continue;
|
|
if (patternCount + rule.patterns.length > MAX_ACTIVE_PLUGIN_DECORATION_PATTERNS) continue;
|
|
seen.add(rule.id);
|
|
result.push(rule);
|
|
patternCount += rule.patterns.length;
|
|
if (result.length >= maximum) return freezeArray(result);
|
|
}
|
|
}
|
|
return freezeArray(result);
|
|
}
|