///|
fn extended_string_function(name : String) -> Bool {
["SUBSTR", "STRBEFORE", "STRAFTER", "ENCODE_FOR_URI", "REGEX", "REPLACE"].contains(
name,
)
}
///|
fn simple_string(t : Term) -> String raise QueryError {
if t.language is Some(_) {
raise Evaluation("plain string required")
}
string_value(t)
}
///|
fn substring_position(
t : Term,
budget : Budget,
) -> @bigint.BigInt raise QueryError {
match numeric(t, budget.limits.max_numeric_digits) {
Exact(value, 0, false) => value
_ => raise Evaluation("SUBSTR requires integer positions")
}
}
///|
fn clamp_offset(value : @bigint.BigInt, size : Int32) -> Int32 {
if value <= @bigint.BigInt::from_int(0) {
0
} else if value >= @bigint.BigInt::from_int(size) {
size
} else {
value.to_int()
}
}
///|
fn encode_for_uri(text : String, budget : Budget) -> String raise QueryError {
let hex = "0123456789ABCDEF".to_array()
let output = StringBuilder()
for byte in @utf8.encode(text) {
budget.tick()
let code = byte.to_int()
let c = code.unsafe_to_char()
if digit(c) ||
(c >= 'A' && c <= 'Z') ||
(c >= 'a' && c <= 'z') ||
c == '-' ||
c == '.' ||
c == '_' ||
c == '~' {
output.write_char(c)
} else {
output.write_char('%')
output.write_char(hex[code / 16])
output.write_char(hex[code % 16])
}
}
output.to_string()
}
///|
fn compile_sparql_regex(
pattern : String,
flags : String,
input_size : Int32,
budget : Budget,
) -> @str.Regex raise QueryError {
if pattern.length() > 256 {
raise Resource("regex pattern characters")
}
if flags.to_array().any(c => c != 'i') {
raise Evaluation("supported regex flag: i")
}
// The core engine is automata based. Reject unbounded compilation expansion
// from counted repetition and charge a conservative input/pattern product.
let mut escaped = false
let mut in_class = false
for c in pattern {
if escaped {
escaped = false
} else if c == '\\' {
escaped = true
} else if c == '[' {
in_class = true
} else if c == ']' {
in_class = false
} else if !in_class && (c == '{' || c == '}') {
raise Evaluation("counted repetition is outside the regex profile")
}
}
let cost = (pattern.length() + 1).to_int64() * (input_size.to_int64() + 1L)
if cost > budget.limits.max_steps.to_int64() {
raise Resource("regex work")
}
budget.tick(n=cost.to_int())
let source = if flags.is_empty() { pattern } else { "(?i:" + pattern + ")" }
@str.Regex(source[:]) catch {
_ => raise Evaluation("invalid regular expression")
}
}
///|
priv enum ReplacementPart {
ReplacementText(String)
CaptureIndex(Int32)
}
///|
fn replacement_parts(text : String) -> Array[ReplacementPart] raise QueryError {
let chars = text.to_array()
let result = []
let mut literal_text = StringBuilder()
let mut i = 0
while i < chars.length() {
let c = chars[i]
i += 1
if c == '$' {
if i >= chars.length() || !digit(chars[i]) {
raise Evaluation("invalid REPLACE capture reference")
}
result.push(ReplacementText(literal_text.to_string()))
literal_text = StringBuilder()
let mut group_index = 0
while i < chars.length() && digit(chars[i]) {
if group_index > 256 {
raise Evaluation("REPLACE capture reference out of range")
}
group_index = group_index * 10 + chars[i].to_int() - '0'.to_int()
i += 1
}
result.push(CaptureIndex(group_index))
} else if c == '\\' {
if i >= chars.length() || (chars[i] != '\\' && chars[i] != '$') {
raise Evaluation("invalid REPLACE escape")
}
literal_text.write_char(chars[i])
i += 1
} else {
literal_text.write_char(c)
}
}
result.push(ReplacementText(literal_text.to_string()))
result
}
///|
fn replace_matches(
regex : @str.Regex,
text : String,
replacement : String,
budget : Budget,
) -> String raise QueryError {
if regex.execute(""[:]) is Some(_) {
raise Evaluation("REPLACE pattern can match an empty string")
}
budget.tick(n=replacement.length())
let parts = replacement_parts(replacement)
let output = StringBuilder()
let mut end = 0
for matched in regex.find(text[:]) {
let start = matched.before().length()
let size = matched.content().length()
if size == 0 {
raise Evaluation("REPLACE pattern matched an empty string")
}
output.write_view(text[end:start])
end = start + size
for part in parts {
budget.tick()
let fragment = match part {
ReplacementText(value) => value[:]
CaptureIndex(index) =>
if index == 0 {
matched.content()
} else {
matched.group(index).unwrap_or(""[:])
}
}
budget.tick(n=fragment.length())
output.write_view(fragment)
}
}
output.write_view(text[end:])
output.to_string()
}
///|
fn evaluate_string_function(
name : String,
values : Array[Term],
budget : Budget,
) -> Term raise QueryError {
let (minimum, maximum) = match name {
"SUBSTR" | "REGEX" => (2, 3)
"REPLACE" => (3, 4)
"ENCODE_FOR_URI" => (1, 1)
_ => (2, 2)
}
if values.length() < minimum || values.length() > maximum {
raise Evaluation(name + " arity")
}
let first = values[0]
let text = string_value(first)
budget.tick(n=text.length())
match name {
"SUBSTR" => {
let chars = text.to_array()
let start = substring_position(values[1], budget) -
@bigint.BigInt::from_int(1)
let from = clamp_offset(start, chars.length())
let to = if values.length() == 3 {
clamp_offset(
start + substring_position(values[2], budget),
chars.length(),
)
} else {
chars.length()
}
string_result(
if to <= from {
""
} else {
String::from_array(chars[from:to])
},
first,
)
}
"STRBEFORE" | "STRAFTER" => {
let second = values[1]
compatible_strings(first, second)
budget.tick(n=second.lexical.length())
if second.lexical.is_empty() {
return string_result(if name == "STRBEFORE" { "" } else { text }, first)
}
match text.split_once(second.lexical) {
Some((left, right)) =>
string_result(
(if name == "STRBEFORE" { left } else { right }).to_owned(),
first,
)
None => literal("")
}
}
"ENCODE_FOR_URI" => literal(encode_for_uri(text, budget))
_ => {
let pattern = simple_string(values[1])
let flags = if values.length() == maximum {
simple_string(values[maximum - 1])
} else {
""
}
let regex = compile_sparql_regex(pattern, flags, text.length(), budget)
if name == "REGEX" {
boolean(regex.execute(text[:]) is Some(_))
} else {
string_result(
replace_matches(regex, text, simple_string(values[2]), budget),
first,
)
}
}
}
}