Current section

Files

Jump to
caffeine_lang src caffeine_query_language parser.gleam
Raw

src/caffeine_query_language/parser.gleam

import caffeine_lang/errors.{type CompilationError}
import caffeine_query_language/ast.{
type Comparator, type CqlParsed, type Exp, type Operator, type TimeSliceExp,
Add, Div, GreaterThan, GreaterThanOrEqualTo, LessThan, LessThanOrEqualTo, Mul,
OperatorExpr, Primary, PrimaryExp, PrimaryWord, Sub, TimeSliceExp,
TimeSliceExpr, Word,
}
import gleam/bool
import gleam/float
import gleam/option.{type Option}
import gleam/result
import gleam/string
/// Parses a CQL expression string into an Exp AST node.
/// Returns an error if the input cannot be parsed.
@internal
pub fn parse_expr(input: String) -> Result(Exp(CqlParsed), String) {
let trimmed = string.trim(input)
case is_fully_parenthesized(trimmed) {
True -> {
let inner = string.slice(trimmed, 1, string.length(trimmed) - 2)
use inner_exp <- result.try(parse_expr(inner))
Ok(Primary(PrimaryExp(inner_exp)))
}
False -> {
let operators = [#("+", Add), #("-", Sub), #("*", Mul), #("/", Div)]
try_operators(trimmed, operators)
}
}
}
fn is_fully_parenthesized(input: String) -> Bool {
string.starts_with(input, "(")
&& string.ends_with(input, ")")
&& { string.length(input) >= 2 && is_balanced_parens(input, 1, 1) }
}
fn try_operators(
input: String,
operators: List(#(String, Operator)),
) -> Result(Exp(CqlParsed), String) {
case operators {
[] -> {
case try_parse_keyword_expr(input) {
Ok(exp) -> Ok(exp)
Error(err) -> {
use <- bool.guard(
when: string.starts_with(input, "time_slice(")
&& string.ends_with(input, ")"),
return: Error(err),
)
let word = Word(input)
Ok(Primary(PrimaryWord(word)))
}
}
}
[#(op_str, op), ..rest] -> {
case find_operator(input, op_str) {
Ok(#(left, right)) -> {
use left_exp <- result.try(parse_expr(left))
use right_exp <- result.try(parse_expr(right))
Ok(OperatorExpr(left_exp, right_exp, op))
}
Error(_) -> try_operators(input, rest)
}
}
}
}
/// Attempts to parse a keyword expression like "time_slice(...)".
/// Returns Error if the input is not a keyword expression.
fn try_parse_keyword_expr(input: String) -> Result(Exp(CqlParsed), String) {
use <- bool.guard(
when: !{
string.starts_with(input, "time_slice(") && string.ends_with(input, ")")
},
return: Error("Not a keyword expression"),
)
let prefix_len = string.length("time_slice(")
let inner_len = string.length(input) - prefix_len - 1
let inner = string.slice(input, prefix_len, inner_len)
use spec <- result.try(parse_time_slice_spec(inner))
Ok(TimeSliceExpr(spec))
}
/// Parses the inner content of a time_slice expression.
/// Format: "<query> <comparator> <threshold> per <interval>"
/// Example: "avg:system.cpu > 80 per 300s"
fn parse_time_slice_spec(input: String) -> Result(TimeSliceExp, String) {
let trimmed = string.trim(input)
case trimmed {
"" -> Error("Empty time_slice expression")
_ -> {
use #(query, comparator, rest) <- result.try(find_comparator(trimmed))
let query_trimmed = string.trim(query)
case query_trimmed {
"" -> Error("Missing query in time_slice expression")
_ -> {
use #(threshold_str, interval_str) <- result.try(split_on_per(rest))
use threshold <- result.try(parse_threshold(threshold_str))
use interval_seconds <- result.try(parse_interval(interval_str))
Ok(TimeSliceExp(
query: query_trimmed,
comparator: comparator,
threshold: threshold,
interval_seconds: interval_seconds,
))
}
}
}
}
}
/// Finds a comparator in the input and splits into (query, comparator, rest).
fn find_comparator(
input: String,
) -> Result(#(String, Comparator, String), String) {
let comparators = [
#(">=", GreaterThanOrEqualTo),
#("<=", LessThanOrEqualTo),
#(">", GreaterThan),
#("<", LessThan),
]
find_comparator_loop(input, comparators)
}
fn find_comparator_loop(
input: String,
comparators: List(#(String, Comparator)),
) -> Result(#(String, Comparator, String), String) {
case comparators {
[] -> Error("No comparator found in time_slice expression")
[#(comp_str, comp), ..rest] -> {
case find_substring_position(input, comp_str) {
option.Some(pos) -> {
let query = string.slice(input, 0, pos)
let rest_start = pos + string.length(comp_str)
let rest_len = string.length(input) - rest_start
let rest_str = string.slice(input, rest_start, rest_len)
Ok(#(query, comp, rest_str))
}
option.None -> find_comparator_loop(input, rest)
}
}
}
}
/// Finds the position of a substring in a string.
fn find_substring_position(haystack: String, needle: String) -> Option(Int) {
find_substring_position_loop(
haystack,
needle,
0,
string.length(needle),
string.length(haystack),
)
}
fn find_substring_position_loop(
haystack: String,
needle: String,
pos: Int,
needle_len: Int,
haystack_len: Int,
) -> Option(Int) {
use <- bool.guard(when: pos + needle_len > haystack_len, return: option.None)
use <- bool.guard(
when: string.slice(haystack, pos, needle_len) == needle,
return: option.Some(pos),
)
find_substring_position_loop(
haystack,
needle,
pos + 1,
needle_len,
haystack_len,
)
}
/// Splits on "per" keyword, returning (threshold_str, interval_str).
fn split_on_per(input: String) -> Result(#(String, String), String) {
case find_substring_position(input, "per") {
option.Some(pos) -> {
let threshold_str = string.trim(string.slice(input, 0, pos))
let rest_start = pos + 3
let rest_len = string.length(input) - rest_start
let interval_str = string.trim(string.slice(input, rest_start, rest_len))
Ok(#(threshold_str, interval_str))
}
option.None -> Error("Missing 'per' keyword in time_slice expression")
}
}
/// Parses a threshold value as a float.
fn parse_threshold(input: String) -> Result(Float, String) {
let trimmed = string.trim(input)
case trimmed {
"" -> Error("Missing threshold in time_slice expression")
_ ->
case float.parse(trimmed) {
Ok(f) -> Ok(f)
Error(_) ->
case parse_int_as_float(trimmed) {
Ok(f) -> Ok(f)
Error(_) ->
Error(
"Invalid threshold '" <> trimmed <> "' in time_slice expression",
)
}
}
}
}
/// Parses an integer string as a float.
fn parse_int_as_float(input: String) -> Result(Float, String) {
use <- bool.guard(
when: string.contains(input, "."),
return: Error("Not an integer"),
)
float.parse(input <> ".0")
|> result.map_error(fn(_) { "Invalid number" })
}
/// Parses an interval like "10s", "5m", "1h", "500ms", "1d" into seconds.
fn parse_interval(input: String) -> Result(Float, String) {
let trimmed = string.trim(input)
case trimmed {
"" -> Error("Missing interval in time_slice expression")
_ -> {
let len = string.length(trimmed)
// Check for the 2-char "ms" unit before falling back to 1-char units.
let #(unit, number_part) = case
len >= 3 && string.slice(trimmed, len - 2, 2) == "ms"
{
True -> #("ms", string.slice(trimmed, 0, len - 2))
False -> #(
string.slice(trimmed, len - 1, 1),
string.slice(trimmed, 0, len - 1),
)
}
use multiplier <- result.try(case unit {
"ms" -> Ok(0.001)
"s" -> Ok(1.0)
"m" -> Ok(60.0)
"h" -> Ok(3600.0)
"d" -> Ok(86_400.0)
_ ->
Error(
"Invalid interval unit '"
<> unit
<> "' (expected ms, s, m, h, or d)",
)
})
use number <- result.try(case float.parse(number_part) {
Ok(f) -> Ok(f)
Error(_) ->
case parse_int_as_float(number_part) {
Ok(f) -> Ok(f)
Error(_) -> Error("Invalid interval number '" <> number_part <> "'")
}
})
Ok(number *. multiplier)
}
}
}
fn find_operator(
input: String,
operator: String,
) -> Result(#(String, String), CompilationError) {
find_rightmost_operator_at_level(input, operator, 0, 0, -1)
}
/// Checks if parentheses are balanced in the input string starting from a position.
/// Used to validate parenthesized expressions during parsing.
@internal
pub fn is_balanced_parens(input: String, pos: Int, count: Int) -> Bool {
is_balanced_parens_loop(input, pos, count, string.length(input))
}
/// Internal loop with pre-computed input length.
fn is_balanced_parens_loop(
input: String,
pos: Int,
count: Int,
input_len: Int,
) -> Bool {
use <- bool.guard(when: pos >= input_len, return: count == 0)
let new_count = count_parens(count, input, pos)
let does_not_close_too_early = !{ { new_count == 0 } && pos != input_len - 1 }
does_not_close_too_early
&& is_balanced_parens_loop(input, pos + 1, new_count, input_len)
}
/// Returns true if the position is at the last character of the input string.
@internal
pub fn is_last_char(input: String, pos: Int) -> Bool {
let is_empty = string.is_empty(input)
let is_last = pos == string.length(input) - 1
is_empty || is_last
}
/// Finds the rightmost occurrence of an operator at parenthesis level 0.
/// Returns the left and right parts of the expression split at the operator.
@internal
pub fn find_rightmost_operator_at_level(
input: String,
operator: String,
start_pos: Int,
paren_level: Int,
rightmost_pos: Int,
) -> Result(#(String, String), CompilationError) {
find_rightmost_operator_at_level_loop(
input,
operator,
start_pos,
paren_level,
rightmost_pos,
string.length(operator),
string.length(input),
)
}
/// Internal loop with pre-computed lengths.
fn find_rightmost_operator_at_level_loop(
input: String,
operator: String,
start_pos: Int,
paren_level: Int,
rightmost_pos: Int,
operator_length: Int,
input_len: Int,
) -> Result(#(String, String), CompilationError) {
case start_pos >= input_len {
True ->
case rightmost_pos {
-1 -> Error(errors.cql_parser_error(msg: "Operator not found"))
pos -> {
let left = string.trim(string.slice(input, 0, pos))
let right_start = pos + operator_length
let right_length = input_len - right_start
let right =
string.trim(string.slice(input, right_start, right_length))
Ok(#(left, right))
}
}
False -> {
let new_paren_level = count_parens(paren_level, input, start_pos)
let new_rightmost_pos = case
new_paren_level == 0
&& string.slice(input, start_pos, operator_length) == operator
{
True -> start_pos
False -> rightmost_pos
}
find_rightmost_operator_at_level_loop(
input,
operator,
start_pos + 1,
new_paren_level,
new_rightmost_pos,
operator_length,
input_len,
)
}
}
}
fn count_parens(cur_count: Int, input: String, pos: Int) -> Int {
let char = string.slice(input, pos, 1)
case char {
"(" -> cur_count + 1
")" -> cur_count - 1
_ -> cur_count
}
}