use std::sync::Arc;
use crate::{PartialParse, SubtreeProvider};
pub trait MetricsqlCatalog: Send + Sync + 'static {
fn metric_names(&self, prefix: &str) -> Vec<String>;
fn label_keys(&self, metric: &str, prefix: &str) -> Vec<String>;
fn label_values(&self, metric: &str, label: &str, prefix: &str) -> Vec<String>;
}
pub const METRICSQL_FUNCTIONS: &[&str] = &[
"rate", "irate", "increase", "increase_pure", "delta", "idelta",
"deriv", "deriv_fast", "predict_linear", "holt_winters",
"changes", "changes_prometheus", "resets",
"avg_over_time", "min_over_time", "max_over_time", "sum_over_time",
"count_over_time", "stddev_over_time", "stdvar_over_time",
"first_over_time", "last_over_time", "quantile_over_time",
"median_over_time", "mode_over_time", "absent_over_time",
"present_over_time", "distinct_over_time", "histogram_over_time",
"rollup", "rollup_rate", "rollup_increase", "rollup_delta",
"rollup_deriv", "rollup_scrape_interval", "rollup_candlestick",
"lag", "lifetime", "scrape_interval",
"sum", "min", "max", "avg", "stddev", "stdvar", "count",
"count_values", "bottomk", "topk", "quantile", "median", "group",
"limitk", "any", "geomean", "histogram", "outliersk", "mode",
"zscore", "share", "absent",
"abs", "absent", "ceil", "clamp_max", "clamp_min", "clamp",
"exp", "floor", "ln", "log2", "log10", "round", "scalar", "sgn",
"sort", "sort_desc", "sort_by_label", "sort_by_label_desc",
"sqrt", "time", "timestamp", "vector", "year", "month", "day_of_month",
"day_of_week", "days_in_month", "minute", "hour",
"label_replace", "label_join", "label_set", "label_del",
"label_keep", "label_lowercase", "label_uppercase", "label_value",
"label_match", "label_mismatch", "label_copy", "label_move",
"label_transform", "labels_equal",
"histogram_quantile", "histogram_share", "histogram_avg",
"histogram_stddev", "histogram_stdvar", "buckets_limit",
"prometheus_buckets",
"pi", "rand", "rand_normal", "rand_exponential", "now", "step",
"start", "end",
];
pub const METRICSQL_AGGR_MODIFIERS: &[&str] = &[
"by", "without",
];
pub const METRICSQL_BIN_OPS: &[&str] = &[
"and", "or", "unless", "ignoring", "on", "group_left", "group_right",
"atan2", "+", "-", "*", "/", "%", "^",
"==", "!=", ">", "<", ">=", "<=",
];
pub const METRICSQL_TIME_UNITS: &[&str] = &[
"ms", "s", "m", "h", "d", "w", "y", "i",
];
pub const METRICSQL_KEYWORDS: &[&str] = &[
"bool", "offset", "@", "start()", "end()", "default",
];
pub fn metricsql_provider(catalog: Arc<dyn MetricsqlCatalog>) -> SubtreeProvider {
Arc::new(move |pp: &PartialParse| {
complete_metricsql(&*catalog, pp)
})
}
fn complete_metricsql(
catalog: &dyn MetricsqlCatalog,
pp: &PartialParse,
) -> Vec<String> {
let bs = pp.bracket_state();
let before = pp.before_cursor();
let ident = pp.ident_before_cursor();
if bs.inside_quote.is_some() {
if let Some(LabelValueContext { metric, label }) = label_value_context(before) {
let prefix = current_quoted_prefix(before);
return catalog.label_values(metric, label, prefix);
}
return Vec::new();
}
if bs.bracket > 0 {
let unit_prefix: &str = ident.trim_start_matches(|c: char| c.is_ascii_digit());
return METRICSQL_TIME_UNITS.iter()
.filter(|u| u.starts_with(unit_prefix))
.map(|u| u.to_string())
.collect();
}
if bs.brace > 0 {
let trig = pp.trigger_char();
match trig {
Some('=') | Some('~') => {
if let Some(LabelValueContext { metric, label }) = label_value_context(before) {
return catalog.label_values(metric, label, "")
.into_iter()
.map(|v| format!("\"{}\"", v))
.collect();
}
return Vec::new();
}
_ => {
let metric = metric_name_before_brace(before).unwrap_or("");
return catalog.label_keys(metric, ident);
}
}
}
if let Some(prev_kw) = preceding_keyword(before) {
if prev_kw == "by" || prev_kw == "without" {
return catalog.label_keys("", ident);
}
if prev_kw == "offset" {
return METRICSQL_TIME_UNITS.iter()
.filter(|u| u.starts_with(ident))
.map(|u| u.to_string())
.collect();
}
}
if let Some(prev_ident) = preceding_ident(before) {
if is_aggregation_function(prev_ident) {
return METRICSQL_AGGR_MODIFIERS.iter()
.filter(|m| m.starts_with(ident))
.map(|m| m.to_string())
.collect();
}
}
let mut out: Vec<String> = METRICSQL_FUNCTIONS.iter()
.filter(|f| f.starts_with(ident))
.map(|f| f.to_string())
.collect();
out.extend(catalog.metric_names(ident));
out.sort();
out.dedup();
out
}
struct LabelValueContext<'a> {
metric: &'a str,
label: &'a str,
}
fn label_value_context(before: &str) -> Option<LabelValueContext<'_>> {
let bytes = before.as_bytes();
let mut in_quote: Option<u8> = None;
let mut last_eq: Option<usize> = None;
let mut i = 0;
while i < bytes.len() {
let c = bytes[i];
match in_quote {
Some(q) => {
if c == b'\\' { i += 2; continue; }
if c == q { in_quote = None; }
}
None => {
match c {
b'"' | b'\'' => { in_quote = Some(c); }
b'=' => { last_eq = Some(i); }
_ => {}
}
}
}
i += 1;
}
let eq = last_eq?;
let key_end = eq;
let mut key_start = key_end;
while key_start > 0 && is_label_char(bytes[key_start - 1] as char) {
key_start -= 1;
}
let label = &before[key_start..key_end];
if label.is_empty() { return None; }
let mut j = key_start;
while j > 0 {
let c = bytes[j - 1];
if c == b'{' { j -= 1; break; }
j -= 1;
}
if j == 0 || bytes[j] != b'{' { return None; }
let metric_end = j;
let mut metric_start = metric_end;
while metric_start > 0 && is_label_char(bytes[metric_start - 1] as char) {
metric_start -= 1;
}
let metric = &before[metric_start..metric_end];
Some(LabelValueContext { metric, label })
}
fn metric_name_before_brace(before: &str) -> Option<&str> {
let bytes = before.as_bytes();
let brace = bytes.iter().rposition(|&b| b == b'{')?;
let metric_end = brace;
let mut metric_start = metric_end;
while metric_start > 0 && is_label_char(bytes[metric_start - 1] as char) {
metric_start -= 1;
}
if metric_end == metric_start { return None; }
Some(&before[metric_start..metric_end])
}
fn current_quoted_prefix(before: &str) -> &str {
let bytes = before.as_bytes();
let mut i = bytes.len();
while i > 0 {
let c = bytes[i - 1];
if c == b'"' || c == b'\'' {
if i >= 2 && bytes[i - 2] == b'\\' { i -= 1; continue; }
return &before[i..];
}
i -= 1;
}
before
}
fn preceding_keyword(before: &str) -> Option<&str> {
let trimmed = before.trim_end_matches(|c: char| is_label_char(c));
let trimmed = trimmed.trim_end_matches(|c: char| {
c.is_whitespace() || c == '(' || c == ')' || c == ','
});
let last_word = trimmed
.rsplit(|c: char| {
c.is_whitespace() || c == '(' || c == ')' || c == ','
})
.next()?;
if last_word.is_empty() { return None; }
Some(last_word)
}
fn preceding_ident(before: &str) -> Option<&str> {
preceding_keyword(before)
}
fn is_aggregation_function(name: &str) -> bool {
matches!(
name,
"sum" | "min" | "max" | "avg" | "stddev" | "stdvar" | "count"
| "count_values" | "bottomk" | "topk" | "quantile" | "median"
| "group" | "limitk" | "any" | "geomean" | "histogram"
| "outliersk" | "mode" | "zscore" | "share"
)
}
#[inline]
fn is_label_char(c: char) -> bool {
c.is_ascii_alphanumeric() || c == '_'
}
#[cfg(test)]
mod tests {
use super::*;
struct StubCatalog;
impl MetricsqlCatalog for StubCatalog {
fn metric_names(&self, prefix: &str) -> Vec<String> {
["up", "node_cpu_seconds_total", "http_requests_total",
"process_cpu_seconds", "node_memory_MemAvailable_bytes"]
.iter()
.filter(|n| n.starts_with(prefix))
.map(|s| s.to_string())
.collect()
}
fn label_keys(&self, _metric: &str, prefix: &str) -> Vec<String> {
["job", "instance", "mode", "code", "method", "le"]
.iter()
.filter(|n| n.starts_with(prefix))
.map(|s| s.to_string())
.collect()
}
fn label_values(&self, _metric: &str, label: &str, prefix: &str) -> Vec<String> {
let pool: &[&str] = match label {
"job" => &["prometheus", "node_exporter", "api_gateway"],
"instance" => &["node-1:9100", "node-2:9100", "node-3:9100"],
"mode" => &["idle", "user", "system", "iowait", "irq", "softirq"],
"code" => &["200", "201", "301", "404", "500", "503"],
"method" => &["GET", "POST", "PUT", "DELETE", "PATCH"],
"le" => &["0.005", "0.01", "0.025", "0.05", "0.1", "0.25", "+Inf"],
_ => &[],
};
pool.iter()
.filter(|v| v.starts_with(prefix))
.map(|s| s.to_string())
.collect()
}
}
fn pp_at_end<'a>(line: &'a str) -> PartialParse<'a> {
PartialParse {
completed: vec![],
partial: "",
tree_path: vec![],
raw_line: line,
cursor_offset: line.len(),
}
}
fn run(line: &str) -> Vec<String> {
let cat = StubCatalog;
complete_metricsql(&cat, &pp_at_end(line))
}
#[test]
fn top_level_offers_metrics_and_functions() {
let out = run("");
assert!(out.iter().any(|s| s == "up"));
assert!(out.iter().any(|s| s == "rate"));
assert!(out.iter().any(|s| s == "histogram_quantile"));
}
#[test]
fn top_level_filters_by_prefix() {
let out = run("hist");
assert!(out.iter().any(|s| s == "histogram_quantile"));
assert!(out.iter().any(|s| s == "histogram_over_time"));
assert!(!out.iter().any(|s| s == "rate"),
"non-matching candidate should be filtered: {:?}", out);
}
#[test]
fn metric_name_prefix_returns_only_matches() {
let out = run("node_");
assert!(out.iter().any(|s| s == "node_cpu_seconds_total"));
assert!(out.iter().any(|s| s == "node_memory_MemAvailable_bytes"));
assert!(!out.iter().any(|s| s == "up"));
}
#[test]
fn inside_brace_offers_label_keys() {
let out = run("up{");
assert!(out.iter().any(|s| s == "job"));
assert!(out.iter().any(|s| s == "instance"));
}
#[test]
fn inside_brace_with_partial_filters_keys() {
let out = run("up{ins");
assert_eq!(out, vec!["instance".to_string()]);
}
#[test]
fn inside_brace_after_first_matcher_still_offers_keys() {
let out = run("up{job=\"prometheus\", ");
assert!(out.iter().any(|s| s == "instance"));
assert!(out.iter().any(|s| s == "mode"));
}
#[test]
fn after_eq_offers_quoted_label_values() {
let out = run("up{job=");
assert!(out.iter().any(|s| s == "\"prometheus\""));
assert!(out.iter().any(|s| s == "\"node_exporter\""));
}
#[test]
fn inside_open_quote_offers_bare_label_values() {
let out = run("up{job=\"prom");
assert!(out.iter().any(|s| s == "prometheus"));
assert!(!out.iter().any(|s| s == "node_exporter"));
}
#[test]
fn label_value_for_specific_label_only() {
let out = run("up{mode=");
assert!(out.iter().any(|s| s == "\"idle\""));
assert!(!out.iter().any(|s| s == "\"prometheus\""));
}
#[test]
fn http_request_with_code_label() {
let out = run("http_requests_total{code=");
assert!(out.iter().any(|s| s == "\"200\""));
assert!(out.iter().any(|s| s == "\"500\""));
}
#[test]
fn inside_quote_for_method() {
let out = run("http_requests_total{method=\"P");
assert!(out.iter().any(|s| s == "POST"));
assert!(out.iter().any(|s| s == "PUT"));
assert!(out.iter().any(|s| s == "PATCH"));
assert!(!out.iter().any(|s| s == "GET"));
}
#[test]
fn inside_bracket_offers_time_units() {
let out = run("rate(http_requests_total[");
assert!(out.iter().any(|s| s == "s"));
assert!(out.iter().any(|s| s == "m"));
assert!(out.iter().any(|s| s == "h"));
}
#[test]
fn time_unit_prefix_filter() {
let out = run("rate(http_requests_total[5");
assert!(out.iter().any(|s| s == "m"));
assert!(out.iter().any(|s| s == "h"));
}
#[test]
fn after_aggregation_offers_modifiers() {
let out = run("sum ");
assert!(out.iter().any(|s| s == "by"));
assert!(out.iter().any(|s| s == "without"));
}
#[test]
fn after_by_offers_label_keys() {
let out = run("sum by (");
assert!(out.iter().any(|s| s == "job"));
assert!(out.iter().any(|s| s == "instance"));
}
#[test]
fn after_offset_offers_time_units() {
let out = run("rate(http_requests_total[5m]) offset ");
assert!(out.iter().any(|s| s == "m"));
assert!(out.iter().any(|s| s == "h"));
}
#[test]
fn realistic_complex_query_inside_label_value() {
let out = run("sum by (instance) (rate(node_cpu_seconds_total{mode=\"i");
assert!(out.iter().any(|s| s == "idle"));
assert!(out.iter().any(|s| s == "iowait"));
assert!(out.iter().any(|s| s == "irq"));
assert!(!out.iter().any(|s| s == "user"),
"non-matching value should be filtered: {:?}", out);
}
#[test]
fn realistic_complex_query_label_keys_after_comma() {
let out = run("up{job=\"prometheus\", instance=\"node-1:9100\", ");
assert!(out.iter().any(|s| s == "mode"));
}
#[test]
fn histogram_quantile_outer_function() {
let out = run("histogram_q");
assert_eq!(out, vec!["histogram_quantile".to_string()]);
}
#[test]
fn label_value_inside_regex_match() {
let out = run("up{job=~\"prom");
assert!(out.iter().any(|s| s == "prometheus"));
}
#[test]
fn nested_function_with_metric_name() {
let out = run("irate(node_");
assert!(out.iter().any(|s| s == "node_cpu_seconds_total"));
}
}