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 /// Codeunit (UTF-16 on JS, UTF-8 byte on Erlang) at index, or -1 if out of bounds. /// Used in per-position scans where we look for ASCII tokens — bypassing /// gleam_stdlib's grapheme-safe slicing avoids an O(N) Intl.Segmenter setup /// per char (which turns the scans into O(N^2)). @external(erlang, "parser_ffi", "code_unit_at") @external(javascript, "./parser_ffi.mjs", "code_unit_at") fn code_unit_at(s: String, i: Int) -> Int /// True if `haystack[pos..pos+|needle|]` exactly equals `needle` (codeunit-wise). @external(erlang, "parser_ffi", "substring_equals_at") @external(javascript, "./parser_ffi.mjs", "substring_equals_at") fn substring_equals_at(haystack: String, pos: Int, needle: String) -> Bool /// Codeunit count — the indexing space used by code_unit_at / substring_equals_at / /// slice_codeunits. NOT grapheme count (which `string.length` returns and which /// also walks the string via Intl.Segmenter on JS). @external(erlang, "parser_ffi", "code_unit_length") @external(javascript, "./parser_ffi.mjs", "code_unit_length") fn code_unit_length(s: String) -> Int /// Slice by codeunit range. Mirrors `string.slice` but skips the grapheme walk. @external(erlang, "parser_ffi", "slice_codeunits") @external(javascript, "./parser_ffi.mjs", "slice_codeunits") fn slice_codeunits(s: String, start: Int, len: Int) -> String // ASCII codeunits for parens used in count_parens. const open_paren: Int = 0x28 const close_paren: Int = 0x29 /// 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: " per " /// 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 = slice_codeunits(input, 0, pos) let rest_start = pos + code_unit_length(comp_str) let rest_len = code_unit_length(input) - rest_start let rest_str = slice_codeunits(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. Returns a codeunit position. fn find_substring_position(haystack: String, needle: String) -> Option(Int) { find_substring_position_loop( haystack, needle, 0, code_unit_length(needle), code_unit_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: substring_equals_at(haystack, pos, 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(slice_codeunits(input, 0, pos)) let rest_start = pos + 3 let rest_len = code_unit_length(input) - rest_start let interval_str = string.trim(slice_codeunits(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, code_unit_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) } /// 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, code_unit_length(operator), code_unit_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(slice_codeunits(input, 0, pos)) let right_start = pos + operator_length let right_length = input_len - right_start let right = string.trim(slice_codeunits(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 && substring_equals_at(input, start_pos, 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 { case code_unit_at(input, pos) { c if c == open_paren -> cur_count + 1 c if c == close_paren -> cur_count - 1 _ -> cur_count } }