114 lines
4.2 KiB
TypeScript
114 lines
4.2 KiB
TypeScript
import { FIELD_MAP, type FieldDef, type OperatorId } from './fields'
|
|||
|
|
import type { Condition, RuleBuilderState } from './types'
|
||
|
|
|
||
|
|
/** Quotes a value as an expr string literal. */
|
||
|
|
export function quote(value: string): string {
|
||
|
|
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
|
||
|
|
}
|
||
|
|
|
||
|
|
const REGEX_SPECIALS = /[.+?^${}()|[\]\\]/g
|
||
|
|
|
||
|
|
/** Converts a `*.example.com` style pattern into an anchored regular expression. */
|
||
|
|
export function wildcardToRegex(pattern: string): string {
|
||
|
|
return `^${pattern.replace(REGEX_SPECIALS, '\\$&').replace(/\*/g, '.*')}$`
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface WildcardExplain {
|
||
|
|
kind: 'exact' | 'suffix' | 'prefix' | 'contains' | 'regex' | 'any'
|
||
|
|
/** The meaningful part of the pattern, e.g. ".example.com" for "*.example.com". */
|
||
|
|
value: string
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Describes how a wildcard pattern will be matched, for the inline hint. */
|
||
|
|
export function explainWildcard(pattern: string): WildcardExplain {
|
||
|
|
const p = pattern.trim()
|
||
|
|
if (p === '*') return { kind: 'any', value: '' }
|
||
|
|
if (!p.includes('*')) return { kind: 'exact', value: p }
|
||
|
|
const starts = p.startsWith('*')
|
||
|
|
const ends = p.endsWith('*')
|
||
|
|
const core = p.replace(/^\*/, '').replace(/\*$/, '')
|
||
|
|
if (core.includes('*')) return { kind: 'regex', value: wildcardToRegex(p) }
|
||
|
|
if (starts && ends) return { kind: 'contains', value: core }
|
||
|
|
if (starts) return { kind: 'suffix', value: core }
|
||
|
|
if (ends) return { kind: 'prefix', value: core }
|
||
|
|
return { kind: 'regex', value: wildcardToRegex(p) }
|
||
|
|
}
|
||
|
|
|
||
|
|
function wildcardTerm(subject: string, pattern: string): string {
|
||
|
|
const info = explainWildcard(pattern)
|
||
|
|
switch (info.kind) {
|
||
|
|
case 'any':
|
||
|
|
return `${subject} != ""`
|
||
|
|
case 'exact':
|
||
|
|
return `${subject} == ${quote(info.value)}`
|
||
|
|
case 'suffix':
|
||
|
|
return `${subject} endsWith ${quote(info.value)}`
|
||
|
|
case 'prefix':
|
||
|
|
return `${subject} startsWith ${quote(info.value)}`
|
||
|
|
case 'contains':
|
||
|
|
return `${subject} contains ${quote(info.value)}`
|
||
|
|
case 'regex':
|
||
|
|
return `${subject} matches ${quote(info.value)}`
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Builds the expression for one value of a condition. */
|
||
|
|
function term(field: FieldDef, op: OperatorId, subject: string, value: string): string {
|
||
|
|
const v = value.trim()
|
||
|
|
switch (op) {
|
||
|
|
case 'is':
|
||
|
|
return field.type === 'port' ? `${subject} == ${v}` : `${subject} == ${quote(v)}`
|
||
|
|
case 'subdomain':
|
||
|
|
return `(${subject} == ${quote(v)} || ${subject} endsWith ${quote('.' + v)})`
|
||
|
|
case 'wildcard':
|
||
|
|
return wildcardTerm(subject, v)
|
||
|
|
case 'contains':
|
||
|
|
case 'startsWith':
|
||
|
|
case 'endsWith':
|
||
|
|
return `${subject} ${op} ${quote(v)}`
|
||
|
|
case 'regex':
|
||
|
|
return `${subject} matches ${quote(v)}`
|
||
|
|
case 'geosite':
|
||
|
|
return `geosite(${subject}, ${quote(v.toLowerCase())})`
|
||
|
|
case 'cidr':
|
||
|
|
return `cidr(${subject}, ${quote(v)})`
|
||
|
|
case 'geoip':
|
||
|
|
return `geoip(${subject}, ${quote(v.toLowerCase())})`
|
||
|
|
case 'detected':
|
||
|
|
return `${v} != nil`
|
||
|
|
case 'range':
|
||
|
|
return ''
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Compiles a single condition into an expression, or "" when incomplete. */
|
||
|
|
export function compileCondition(condition: Condition): string {
|
||
|
|
const field = FIELD_MAP[condition.field]
|
||
|
|
if (!field) return ''
|
||
|
|
const values = condition.values.map((v) => v.trim()).filter(Boolean)
|
||
|
|
|
||
|
|
let body: string
|
||
|
|
if (condition.op === 'range') {
|
||
|
|
const [from, to] = condition.values
|
||
|
|
if (!from?.trim() || !to?.trim()) return ''
|
||
|
|
body = `(${field.expr} >= ${from.trim()} && ${field.expr} <= ${to.trim()})`
|
||
|
|
} else {
|
||
|
|
if (values.length === 0) return ''
|
||
|
|
const subject = field.iter ? (field.item as string) : (field.expr as string)
|
||
|
|
const terms = values.map((v) => term(field, condition.op, subject, v))
|
||
|
|
const joined = terms.length > 1 ? `(${terms.join(' || ')})` : terms[0]
|
||
|
|
body = field.iter ? `any(${field.iter}, {${joined}})` : joined
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!condition.negate) return body
|
||
|
|
return body.startsWith('(') && body.endsWith(')') ? `!${body}` : `!(${body})`
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Compiles the whole builder state into a rule expression. */
|
||
|
|
export function compileRule(state: RuleBuilderState): string {
|
||
|
|
const parts = state.conditions.map(compileCondition).filter(Boolean)
|
||
|
|
if (parts.length === 0) return ''
|
||
|
|
if (parts.length === 1) return parts[0]
|
||
|
|
return parts.join(state.mode === 'and' ? ' && ' : ' || ')
|
||
|
|
}
|