use std::{collections::HashMap, sync::LazyLock};
use pest::Parser as _;
use serde::Serialize;
use wasm_bindgen::prelude::*;
use crate::{
linker::{ArgType, FunctionId, FunctionTrait, Module},
parser::{MPLParser, Rule},
stdlib::STDLIB,
};
use super::Span;
#[derive(Clone, Serialize)]
pub(super) struct CompletionArg {
pub(super) name: &'static str,
#[serde(rename = "type")]
pub(super) typ: ArgType,
}
#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub(super) enum ParamType {
Dataset,
Metric,
Duration,
String,
Int,
Float,
Bool,
Regex,
}
#[derive(Clone, Debug, Serialize)]
pub(super) struct ParamItem {
pub(super) label: std::string::String,
#[serde(rename = "type")]
pub(super) typ: ParamType,
pub(super) optional: bool,
}
#[derive(Clone, Serialize)]
pub(super) struct KeywordItem {
pub(super) label: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) apply: Option<&'static str>,
pub(super) info: &'static str,
}
#[derive(Clone, Serialize)]
pub(super) struct FunctionItem {
pub(super) label: String,
pub(super) args: Vec<CompletionArg>,
pub(super) info: String,
}
#[derive(Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub(super) enum CompletionResult {
Keywords {
#[serde(flatten)]
span: Span,
options: Vec<KeywordItem>,
},
AlignFunctions {
#[serde(flatten)]
span: Span,
options: Vec<FunctionItem>,
},
MapFunctions {
#[serde(flatten)]
span: Span,
options: Vec<FunctionItem>,
},
GroupFunctions {
#[serde(flatten)]
span: Span,
options: Vec<FunctionItem>,
},
BucketFunctions {
#[serde(flatten)]
span: Span,
options: Vec<FunctionItem>,
},
ComputeFunctions {
#[serde(flatten)]
span: Span,
options: Vec<FunctionItem>,
},
Tag {
#[serde(flatten)]
span: Span,
dataset: String,
metric: String,
},
Dataset {
#[serde(flatten)]
span: Span,
},
Metric {
#[serde(flatten)]
span: Span,
dataset: String,
},
Params {
#[serde(flatten)]
span: Span,
options: Vec<ParamItem>,
},
}
impl CompletionResult {
fn retain_options(&mut self, f: impl Fn(&str) -> bool) {
match self {
Self::Keywords { options, .. } => options.retain(|item| f(item.label)),
Self::AlignFunctions { options, .. }
| Self::MapFunctions { options, .. }
| Self::GroupFunctions { options, .. }
| Self::BucketFunctions { options, .. }
| Self::ComputeFunctions { options, .. } => options.retain(|item| f(&item.label)),
Self::Params { options, .. } => options.retain(|item| f(&item.label)),
Self::Tag { .. } | Self::Dataset { .. } | Self::Metric { .. } => {}
}
}
#[cfg(test)]
pub(super) fn kind(&self) -> &'static str {
match self {
Self::Keywords { .. } => "keywords",
Self::AlignFunctions { .. } => "align_functions",
Self::MapFunctions { .. } => "map_functions",
Self::GroupFunctions { .. } => "group_functions",
Self::BucketFunctions { .. } => "bucket_functions",
Self::ComputeFunctions { .. } => "compute_functions",
Self::Tag { .. } => "tag",
Self::Dataset { .. } => "dataset",
Self::Metric { .. } => "metric",
Self::Params { .. } => "params",
}
}
#[cfg(test)]
pub(super) fn option_labels(&self) -> Vec<&str> {
match self {
Self::Keywords { options, .. } => options.iter().map(|o| o.label).collect(),
Self::AlignFunctions { options, .. }
| Self::MapFunctions { options, .. }
| Self::GroupFunctions { options, .. }
| Self::BucketFunctions { options, .. }
| Self::ComputeFunctions { options, .. } => {
options.iter().map(|o| o.label.as_str()).collect()
}
Self::Params { options, .. } => options.iter().map(|o| o.label.as_str()).collect(),
Self::Tag { .. } | Self::Dataset { .. } | Self::Metric { .. } => vec![],
}
}
#[cfg(test)]
pub(super) fn keyword_apply_texts(&self) -> Vec<Option<&str>> {
match self {
Self::Keywords { options, .. } => options.iter().map(|o| o.apply).collect(),
_ => vec![],
}
}
}
#[must_use]
#[wasm_bindgen]
pub fn completions(query: &str, cursor_pos: usize) -> JsValue {
let result = compute_completions(query, cursor_pos);
super::to_js_value(&result)
}
#[derive(Serialize)]
pub(super) struct FunctionInfo {
pub(super) label: String,
pub(super) args: Vec<CompletionArg>,
pub(super) info: Option<String>,
}
#[must_use]
#[wasm_bindgen]
pub fn function_info(label: &str) -> JsValue {
let result = STDLIB.lookup_function(label);
super::to_js_value(&result)
}
fn collect_args<F: FunctionTrait>(f: &F) -> Vec<CompletionArg> {
f.args()
.into_iter()
.map(|a| CompletionArg {
name: a.name,
typ: a.typ,
})
.collect()
}
fn collect_functions_from_module<F: FunctionTrait>(
fns: &HashMap<FunctionId, F>,
prefix: Option<&str>,
) -> Vec<FunctionItem> {
fns.iter()
.map(|(id, f)| {
let label = match prefix {
Some(p) => format!("{p}::{}", id.0),
None => id.0.clone(),
};
FunctionItem {
label,
args: collect_args(f),
info: f.doc().to_string(),
}
})
.collect()
}
impl Module {
fn for_each_module(&self, mut f: impl FnMut(&Module, Option<&str>)) {
Self::for_each_module_rec(self, None, &mut f);
}
fn for_each_module_rec(
module: &Module,
prefix: Option<&str>,
f: &mut dyn FnMut(&Module, Option<&str>),
) {
f(module, prefix);
for sub in module.submodules.values() {
let sub_prefix = match prefix {
Some(p) => format!("{p}::{}", sub.name.0),
None => sub.name.0.clone(),
};
Self::for_each_module_rec(sub, Some(&sub_prefix), f);
}
}
fn function_names<V>(
&self,
accessor: impl Fn(&Module) -> &HashMap<FunctionId, V>,
) -> Vec<String> {
let mut names = Vec::new();
self.for_each_module(|module, prefix| {
for key in accessor(module).keys() {
match prefix {
Some(p) => names.push(format!("{p}::{}", key.0)),
None => names.push(key.0.clone()),
}
}
});
names
}
fn completion_items<F: FunctionTrait>(
&self,
accessor: impl Fn(&Module) -> &HashMap<FunctionId, F>,
) -> Vec<FunctionItem> {
let mut items = Vec::new();
self.for_each_module(|module, prefix| {
let fns = accessor(module);
if !fns.is_empty() {
items.extend(collect_functions_from_module(fns, prefix));
}
});
items
}
fn function_info_by_id(&self, fn_id: &str) -> Option<FunctionInfo> {
fn try_map<F: FunctionTrait>(
fns: &HashMap<FunctionId, F>,
fn_id: &str,
label: &str,
) -> Option<FunctionInfo> {
let f = fns.get(fn_id)?;
Some(FunctionInfo {
label: label.to_string(),
args: collect_args(f),
info: Some(f.doc().to_string()),
})
}
None.or_else(|| try_map(&self.mapping_functions, fn_id, fn_id))
.or_else(|| try_map(&self.align_functions, fn_id, fn_id))
.or_else(|| try_map(&self.group_functions, fn_id, fn_id))
.or_else(|| try_map(&self.compute_functions, fn_id, fn_id))
.or_else(|| try_map(&self.bucket_functions, fn_id, fn_id))
}
fn resolve_module_path(&self, path: &str) -> Option<&Module> {
let mut current = self;
for segment in path.split("::") {
current = current.submodules.get(segment)?;
}
Some(current)
}
fn lookup_unqualified(&self, fn_name: &str) -> Option<FunctionInfo> {
for sub in self.submodules.values() {
if let Some(mut info) = sub.function_info_by_id(fn_name) {
info.label = format!("{}::{fn_name}", sub.name.0);
return Some(info);
}
if let Some(mut info) = sub.lookup_unqualified(fn_name) {
info.label = format!("{}::{}", sub.name.0, info.label);
return Some(info);
}
}
None
}
pub(super) fn lookup_function(&self, label: &str) -> Option<FunctionInfo> {
if let Some((module_path, fn_name)) = label.rsplit_once("::") {
let module = self.resolve_module_path(module_path)?;
let mut info = module.function_info_by_id(fn_name)?;
info.label = label.to_string();
Some(info)
} else {
self.function_info_by_id(label)
.or_else(|| self.lookup_unqualified(label))
}
}
}
static ALIGN_COMPLETIONS: LazyLock<Vec<FunctionItem>> =
LazyLock::new(|| STDLIB.completion_items(|m| &m.align_functions));
static MAP_COMPLETIONS: LazyLock<Vec<FunctionItem>> =
LazyLock::new(|| STDLIB.completion_items(|m| &m.mapping_functions));
static GROUP_COMPLETIONS: LazyLock<Vec<FunctionItem>> =
LazyLock::new(|| STDLIB.completion_items(|m| &m.group_functions));
static BUCKET_COMPLETIONS: LazyLock<Vec<FunctionItem>> =
LazyLock::new(|| STDLIB.completion_items(|m| &m.bucket_functions));
static COMPUTE_COMPLETIONS: LazyLock<Vec<FunctionItem>> =
LazyLock::new(|| STDLIB.completion_items(|m| &m.compute_functions));
pub(super) static MAP_FN_NAMES: LazyLock<Vec<String>> =
LazyLock::new(|| STDLIB.function_names(|m| &m.mapping_functions));
pub(super) static ALIGN_FN_NAMES: LazyLock<Vec<String>> =
LazyLock::new(|| STDLIB.function_names(|m| &m.align_functions));
pub(super) static BUCKET_FN_NAMES: LazyLock<Vec<String>> =
LazyLock::new(|| STDLIB.function_names(|m| &m.bucket_functions));
pub(super) static GROUP_FN_NAMES: LazyLock<Vec<String>> =
LazyLock::new(|| STDLIB.function_names(|m| &m.group_functions));
pub(super) static COMPUTE_FN_NAMES: LazyLock<Vec<String>> =
LazyLock::new(|| STDLIB.function_names(|m| &m.compute_functions));
pub(super) enum QueryContext<'a> {
Subquery(&'a str),
ComputeRulePipe(&'a str),
ComputeTailPipe(&'a str),
}
fn is_compute_paren(bytes: &[u8], pos: usize) -> bool {
let mut j = pos;
loop {
while j > 0 && bytes[j - 1].is_ascii_whitespace() {
j -= 1;
}
if j == 0 {
return true;
}
let line_start = bytes[..j]
.iter()
.rposition(|&b| b == b'\n')
.map_or(0, |p| p + 1);
match find_line_comment(&bytes[line_start..j]) {
Some(offset) => j = line_start + offset,
None => break,
}
}
matches!(bytes[j - 1], b';' | b'(' | b',')
}
fn find_line_comment(line: &[u8]) -> Option<usize> {
let mut i = 0;
while i + 1 < line.len() {
match line[i] {
b'"' | b'`' => {
let delim = line[i];
i += 1;
while i < line.len() && line[i] != delim {
if line[i] == b'\\' {
i += 1;
}
i += 1;
}
if i < line.len() {
i += 1;
}
}
b'/' if line[i + 1] == b'/' => return Some(i),
_ => i += 1,
}
}
None
}
pub(super) fn locate_query_context(text: &str) -> QueryContext<'_> {
let bytes = text.as_bytes();
let len = bytes.len();
let mut stack: Vec<usize> = vec![0];
let mut last_close_brace: Option<usize> = None;
let mut ignored_paren_depth: usize = 0;
let mut i = 0;
while i < len {
if !skip_literal(bytes, len, &mut i) {
match bytes[i] {
b'(' => {
if ignored_paren_depth > 0 {
ignored_paren_depth += 1;
} else if is_compute_paren(bytes, i) {
stack.push(i + 1);
} else {
ignored_paren_depth += 1;
}
}
b')' => {
if ignored_paren_depth > 0 {
ignored_paren_depth -= 1;
} else if stack.len() > 1 {
stack.pop();
if stack.len() == 1 {
last_close_brace = Some(i);
}
}
}
b',' => {
if ignored_paren_depth == 0
&& stack.len() > 1
&& let Some(top) = stack.last_mut()
{
*top = i + 1;
}
}
_ => {}
}
}
i += 1;
}
if stack.len() > 1 {
let start = stack.last().copied().unwrap_or(0);
return QueryContext::Subquery(&text[start..]);
}
if let Some(brace_pos) = last_close_brace {
let outer = &text[brace_pos + 1..];
let pipe_count = count_pipes(outer);
return if pipe_count <= 1 {
QueryContext::ComputeRulePipe(outer)
} else {
QueryContext::ComputeTailPipe(outer)
};
}
QueryContext::Subquery(text)
}
fn count_pipes(text: &str) -> usize {
let bytes = text.as_bytes();
let len = bytes.len();
let mut count = 0;
let mut i = 0;
while i < len {
if !skip_literal(bytes, len, &mut i) && bytes[i] == b'|' {
count += 1;
}
i += 1;
}
count
}
pub(super) fn compute_completions(query: &str, cursor_pos: usize) -> Option<CompletionResult> {
let cursor = cursor_pos.min(query.len());
let (word_start, partial) = extract_partial_word(query, cursor);
let before = &query[..word_start];
let params = extract_declared_params(query);
let span = Span::new(word_start, cursor);
let mut result = match locate_query_context(before) {
QueryContext::Subquery(text) => {
suggest_for_context(text, span, FilterPolicy::Include, ¶ms)
.or_else(|| suggest_for_preamble(text, partial, span))
.or_else(|| suggest_for_source(text, partial, span, ¶ms))?
}
QueryContext::ComputeRulePipe(text) => suggest_for_compute_rule(text, span)?,
QueryContext::ComputeTailPipe(text) => {
suggest_for_context(text, span, FilterPolicy::Exclude, ¶ms)?
}
};
if partial.starts_with('`')
&& let CompletionResult::Tag { ref mut span, .. } = result
{
span.from += 1;
}
let filter_partial = if let CompletionResult::Params { ref span, .. } = result {
&query[span.from..cursor]
} else {
partial.strip_prefix('`').unwrap_or(partial)
};
if !filter_partial.is_empty() {
let lower = filter_partial.to_lowercase();
result.retain_options(|label| label.to_lowercase().starts_with(&lower));
}
Some(result)
}
pub(super) fn extract_partial_word(text: &str, cursor: usize) -> (usize, &str) {
let bytes = &text.as_bytes()[..cursor];
let mut i = bytes.len();
while i > 0 {
match bytes[i - 1] {
b'`' => {
let backtick_pos = i - 1;
i -= 1;
let mut found_open = false;
while i > 0 {
if bytes[i - 1].is_ascii_whitespace() {
break;
}
if bytes[i - 1] == b'`' && !is_char_escaped(bytes, i - 1) {
i -= 1;
found_open = true;
break;
}
i -= 1;
}
if !found_open {
i = backtick_pos;
}
}
c if c.is_ascii_alphanumeric() || c == b'_' || c == b':' || c == b'$' => {
i -= 1;
}
_ => {
let mut j = i - 1;
let mut found_backtick = false;
loop {
if bytes[j] == b'`' && !is_char_escaped(bytes, j) {
found_backtick = true;
i = j;
break;
}
if bytes[j].is_ascii_whitespace() {
break;
}
if j == 0 {
break;
}
j -= 1;
}
if !found_backtick {
break;
}
}
}
}
(i, &text[i..cursor])
}
pub(super) fn is_char_escaped(bytes: &[u8], pos: usize) -> bool {
let mut count = 0u32;
let mut j = pos;
while j > 0 && bytes[j - 1] == b'\\' {
count += 1;
j -= 1;
}
count % 2 == 1
}
fn skip_literal(bytes: &[u8], len: usize, i: &mut usize) -> bool {
match bytes[*i] {
b'"' | b'`' => {
let delim = bytes[*i];
*i += 1;
while *i < len && bytes[*i] != delim {
if bytes[*i] == b'\\' {
*i += 1;
}
*i += 1;
}
true
}
b'/' if *i + 1 < len && bytes[*i + 1] == b'/' => {
while *i < len && bytes[*i] != b'\n' {
*i += 1;
}
true
}
b'/' if preceded_by_eq(bytes, *i) => {
skip_regex_body(bytes, len, i);
true
}
b'/' if is_regex_replace_start(bytes, *i) => {
skip_regex_body(bytes, len, i);
skip_regex_body(bytes, len, i);
true
}
_ => false,
}
}
fn skip_regex_body(bytes: &[u8], len: usize, i: &mut usize) {
*i += 1;
while *i < len && bytes[*i] != b'/' {
if bytes[*i] == b'\\' {
*i += 1;
}
*i += 1;
}
}
fn preceded_by_eq(bytes: &[u8], pos: usize) -> bool {
let mut j = pos;
while j > 0 && bytes[j - 1].is_ascii_whitespace() {
j -= 1;
}
j > 0 && bytes[j - 1] == b'='
}
fn is_regex_replace_start(bytes: &[u8], pos: usize) -> bool {
if pos == 0 || bytes[pos - 1] != b's' {
return false;
}
let mut j = pos - 1;
while j > 0 && bytes[j - 1].is_ascii_whitespace() {
j -= 1;
}
j > 0 && bytes[j - 1] == b'~'
}
fn find_last_pipe(text: &str) -> Option<usize> {
let bytes = text.as_bytes();
let len = bytes.len();
let mut last_pipe = None;
let mut i = 0;
while i < len {
if !skip_literal(bytes, len, &mut i) && bytes[i] == b'|' {
last_pipe = Some(i);
}
i += 1;
}
last_pipe
}
fn extract_source_info(text: &str) -> Option<(String, String)> {
let bytes = text.as_bytes();
let len = bytes.len();
let mut source_start = 0;
let mut first_pipe = len;
let mut i = 0;
while i < len {
if !skip_literal(bytes, len, &mut i) {
match bytes[i] {
b';' => source_start = i + 1,
b'|' => {
first_pipe = i;
break;
}
_ => {}
}
}
i += 1;
}
let source = text[source_start..first_pipe].trim();
extract_source_via_parser(source)
}
fn extract_source_via_parser(source: &str) -> Option<(String, String)> {
let pairs = MPLParser::parse(Rule::source, source).ok()?;
let source_pair = pairs.into_iter().next()?;
let metric_id = source_pair
.into_inner()
.find(|p| p.as_rule() == Rule::metric_id)?;
let mut dataset = None;
let mut metric = None;
for pair in metric_id.into_inner() {
match pair.as_rule() {
Rule::dataset => dataset = Some(extract_ident_name(pair)),
Rule::metric_name => metric = Some(extract_ident_name(pair)),
_ => {}
}
}
let (dataset, metric) = (dataset?, metric?);
if dataset.is_empty()
|| metric.is_empty()
|| dataset.starts_with('$')
|| metric.starts_with('$')
{
return None;
}
Some((dataset, metric))
}
fn extract_ident_name(pair: pest::iterators::Pair<'_, Rule>) -> String {
let Some(inner) = pair.into_inner().next() else {
return String::new();
};
match inner.as_rule() {
Rule::plain_ident | Rule::param_ident => inner.as_str().to_string(),
Rule::escaped_ident => inner
.into_inner()
.next()
.map(|p| p.as_str().to_string())
.unwrap_or_default(),
_ => String::new(),
}
}
pub(super) fn extract_declared_params(text: &str) -> Vec<ParamItem> {
let mut params = Vec::new();
for line in text.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with("//") || trimmed.starts_with("set ") {
continue;
}
if let Some(rest) = trimmed.strip_prefix("param ") {
if let Some(item) = parse_param_decl(rest) {
params.push(item);
}
continue;
}
break;
}
params
}
fn parse_param_decl(rest: &str) -> Option<ParamItem> {
let rest = rest.trim().strip_suffix(';')?.trim();
let (name, typ_str) = rest.split_once(':')?;
let name = name.trim();
let typ_str = typ_str.trim();
if !name.starts_with('$') {
return None;
}
let (inner, optional) = match typ_str
.strip_prefix("Option<")
.and_then(|s| s.strip_suffix('>'))
{
Some(inner) => (inner.trim(), true),
None => (typ_str, false),
};
let typ = match inner {
"Dataset" => ParamType::Dataset,
"Metric" => ParamType::Metric,
"Duration" | "duration" => ParamType::Duration,
"string" => ParamType::String,
"int" => ParamType::Int,
"float" => ParamType::Float,
"bool" => ParamType::Bool,
"Regex" => ParamType::Regex,
_ => return None,
};
if optional
&& !matches!(
typ,
ParamType::String
| ParamType::Int
| ParamType::Float
| ParamType::Bool
| ParamType::Regex
)
{
return None;
}
Some(ParamItem {
label: name.to_string(),
typ,
optional,
})
}
fn suggest_params(
span: Span,
params: &[ParamItem],
active_gate: Option<&str>,
allowed: impl Fn(ParamType) -> bool,
) -> Option<CompletionResult> {
let options: Vec<ParamItem> = params
.iter()
.filter(|p| allowed(p.typ))
.filter(|p| !p.optional || active_gate == Some(p.label.as_str()))
.cloned()
.collect();
if options.is_empty() {
None
} else {
Some(CompletionResult::Params { span, options })
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub(super) enum FilterPolicy {
Include,
Exclude,
}
fn pipe_keywords(
span: Span,
policy: FilterPolicy,
allow_sample: bool,
has_optional_params: bool,
) -> CompletionResult {
let mut options = Vec::with_capacity(10);
if allow_sample {
options.push(KeywordItem {
label: "sample",
apply: Some("sample "),
info: "Sample time series at a numeric rate",
});
}
if policy == FilterPolicy::Include {
options.push(KeywordItem {
label: "where",
apply: Some("where "),
info: "Filter time series by label values",
});
if has_optional_params {
options.push(KeywordItem {
label: "ifdef",
apply: Some("ifdef("),
info: "Apply a filter only when an optional param is supplied",
});
}
}
options.extend([
KeywordItem {
label: "map",
apply: Some("map "),
info: "Apply a function to each data point",
},
KeywordItem {
label: "group",
apply: Some("group "),
info: "Group time series by labels",
},
KeywordItem {
label: "align",
apply: Some("align "),
info: "Align time series to a time grid",
},
KeywordItem {
label: "bucket",
apply: Some("bucket "),
info: "Bucket time series into histogram buckets",
},
KeywordItem {
label: "as",
apply: Some("as "),
info: "Rename the metric",
},
]);
CompletionResult::Keywords { span, options }
}
fn suggest_for_compute_rule(text: &str, span: Span) -> Option<CompletionResult> {
let pipe_pos = find_last_pipe(text)?;
let after_pipe = text[pipe_pos + 1..].trim();
if after_pipe.is_empty() {
return Some(CompletionResult::Keywords {
span,
options: vec![KeywordItem {
label: "compute",
apply: Some("compute "),
info: "Compute a new metric from two sources",
}],
});
}
let words: Vec<&str> = after_pipe.split_whitespace().collect();
match words[0] {
"compute" => match words.len() {
1 => None,
2 => Some(CompletionResult::Keywords {
span,
options: vec![KeywordItem {
label: "using",
apply: Some("using "),
info: "Specify the compute function",
}],
}),
_ => {
if *words.last()? == "using" {
Some(CompletionResult::ComputeFunctions {
span,
options: COMPUTE_COMPLETIONS.clone(),
})
} else {
None
}
}
},
_ => None,
}
}
fn suggest_for_context(
before: &str,
span: Span,
policy: FilterPolicy,
params: &[ParamItem],
) -> Option<CompletionResult> {
let pipe_pos = find_last_pipe(before)?;
let after_pipe = before[pipe_pos + 1..].trim();
let allow_sample = policy == FilterPolicy::Include && count_pipes(before) == 1;
let has_optional_params = params.iter().any(|p| p.optional);
if after_pipe.is_empty() {
return Some(pipe_keywords(
span,
policy,
allow_sample,
has_optional_params,
));
}
let words: Vec<&str> = after_pipe.split_whitespace().collect();
let first = words[0];
let last = words.last().copied().unwrap_or(first);
match first {
"where" | "filter" if policy == FilterPolicy::Include => {
suggest_filter_context(before, span, &words, last, params, None)
}
"sample" => None,
f if f.starts_with("ifdef") && policy == FilterPolicy::Include => {
suggest_ifdef_context(before, span, after_pipe, params)
}
"group"
if words.len() >= 2 && words[1] == "by" && (last == "by" || last.ends_with(',')) =>
{
let (dataset, metric) = extract_source_info(before)?;
Some(CompletionResult::Tag {
span,
dataset,
metric,
})
}
_ => suggest_pipe_rule(first, last, &words, span, params),
}
}
fn suggest_ifdef_context(
before: &str,
span: Span,
after_pipe: &str,
params: &[ParamItem],
) -> Option<CompletionResult> {
let open_paren = after_pipe.find('(');
let close_paren = after_pipe.rfind(')');
let open_brace = after_pipe.rfind('{');
if open_paren.is_some() && close_paren.is_none() {
return suggest_optional_params(span, params);
}
if close_paren.is_some() && open_brace.is_none() {
return Some(CompletionResult::Keywords {
span,
options: vec![KeywordItem {
label: "{",
apply: Some("{ where "),
info: "Open the ifdef filter body",
}],
});
}
let brace_idx = open_brace?;
let body = after_pipe[brace_idx + 1..].trim();
let active_gate = open_paren.and_then(|p| extract_ifdef_gate_name(after_pipe, p));
if body.is_empty() {
return Some(CompletionResult::Keywords {
span,
options: vec![KeywordItem {
label: "where",
apply: Some("where "),
info: "Filter time series by label values",
}],
});
}
let body_words: Vec<&str> = body.split_whitespace().collect();
let body_first = body_words[0];
let body_last = body_words.last().copied().unwrap_or(body_first);
if matches!(body_first, "where" | "filter") {
suggest_filter_context(before, span, &body_words, body_last, params, active_gate)
} else {
None
}
}
fn extract_ifdef_gate_name(after_pipe: &str, open_paren: usize) -> Option<&str> {
let after = after_pipe.get(open_paren + 1..)?;
let close = after.find(')')?;
let inner = after[..close].trim();
let rest = inner.strip_prefix('$')?;
if rest.is_empty() || !rest.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
return None;
}
Some(inner)
}
fn suggest_optional_params(span: Span, params: &[ParamItem]) -> Option<CompletionResult> {
let options: Vec<ParamItem> = params.iter().filter(|p| p.optional).cloned().collect();
if options.is_empty() {
None
} else {
Some(CompletionResult::Params { span, options })
}
}
fn suggest_pipe_rule(
first: &str,
last: &str,
words: &[&str],
span: Span,
params: &[ParamItem],
) -> Option<CompletionResult> {
match first {
"align" => match last {
"to" | "over" => suggest_params(span, params, None, |t| t == ParamType::Duration),
"using" => Some(CompletionResult::AlignFunctions {
span,
options: ALIGN_COMPLETIONS.clone(),
}),
_ => {
let has_to = words.contains(&"to");
let has_over = words.contains(&"over");
let mut options = Vec::new();
if !has_to {
options.push(KeywordItem {
label: "to",
apply: Some("to "),
info: "Align to a time interval",
});
}
if has_to && !has_over {
options.push(KeywordItem {
label: "over",
apply: Some("over "),
info: "Specify the lookback window",
});
}
if has_to {
options.push(KeywordItem {
label: "using",
apply: Some("using "),
info: "Specify the align function",
});
}
Some(CompletionResult::Keywords { span, options })
}
},
"map" => {
if words.len() == 1 {
return Some(CompletionResult::MapFunctions {
span,
options: MAP_COMPLETIONS.clone(),
});
}
None
}
"group" => match last {
"by" => None,
"using" => Some(CompletionResult::GroupFunctions {
span,
options: GROUP_COMPLETIONS.clone(),
}),
_ if words.len() >= 2 && words[1] == "by" => Some(CompletionResult::Keywords {
span,
options: vec![KeywordItem {
label: "using",
apply: Some("using "),
info: "Specify the group function",
}],
}),
_ => Some(CompletionResult::Keywords {
span,
options: vec![
KeywordItem {
label: "by",
apply: Some("by "),
info: "Group by labels",
},
KeywordItem {
label: "using",
apply: Some("using "),
info: "Specify the group function",
},
],
}),
},
"bucket" => suggest_bucket_pipe(words, last, span, params),
"as" => Some(CompletionResult::Keywords {
span,
options: vec![],
}),
_ => None,
}
}
fn suggest_bucket_pipe(
words: &[&str],
last: &str,
span: Span,
params: &[ParamItem],
) -> Option<CompletionResult> {
if let Some(result) = suggest_bucket_args(words, span) {
return Some(result);
}
match last {
"by" => None,
"to" => suggest_params(span, params, None, |t| t == ParamType::Duration),
"using" => Some(CompletionResult::BucketFunctions {
span,
options: BUCKET_COMPLETIONS.clone(),
}),
_ => Some(CompletionResult::Keywords {
span,
options: vec![
KeywordItem {
label: "by",
apply: Some("by "),
info: "Bucket by a label",
},
KeywordItem {
label: "to",
apply: Some("to "),
info: "Bucket to a target size",
},
KeywordItem {
label: "using",
apply: Some("using "),
info: "Specify the bucket function",
},
],
}),
}
}
fn extract_enum_values(arg_type: &ArgType) -> Vec<&'static str> {
match arg_type {
ArgType::Enum(values) => values.to_vec(),
ArgType::Repeated { typ, .. } => extract_enum_values(typ),
ArgType::OneOf(types) => types.iter().flat_map(extract_enum_values).collect(),
ArgType::Optional(inner) => extract_enum_values(inner),
ArgType::Float => vec![],
}
}
fn suggest_bucket_args(words: &[&str], span: Span) -> Option<CompletionResult> {
let joined: String = words.join(" ");
let mut depth: i32 = 0;
let mut last_open: Option<usize> = None;
for (i, ch) in joined.char_indices() {
match ch {
'(' => {
depth += 1;
if depth == 1 {
last_open = Some(i);
}
}
')' => {
depth -= 1;
if depth == 0 {
last_open = None;
}
}
_ => {}
}
}
let open = last_open?;
let before_paren = &joined[..open];
let fn_name = before_paren
.trim_end()
.rsplit(|c: char| !c.is_alphanumeric() && c != '_')
.next()
.filter(|s| !s.is_empty())?;
let func = STDLIB.bucket_functions.get(fn_name)?;
let args = func.args();
if args.is_empty() {
return None;
}
let inside = &joined[open + 1..];
let comma_count = inside.chars().filter(|&c| c == ',').count();
let arg_idx = if comma_count < args.len() {
comma_count
} else {
let last = args.len() - 1;
if matches!(args[last].typ, ArgType::Repeated { .. }) {
last
} else {
return None;
}
};
let values = extract_enum_values(&args[arg_idx].typ);
if values.is_empty() {
return None;
}
Some(CompletionResult::Keywords {
span,
options: values
.into_iter()
.map(|v| KeywordItem {
label: v,
apply: None,
info: "",
})
.collect(),
})
}
fn suggest_filter_context(
before: &str,
span: Span,
words: &[&str],
last: &str,
params: &[ParamItem],
active_gate: Option<&str>,
) -> Option<CompletionResult> {
if words.len() == 1 || matches!(last, "and" | "or" | "not" | "(") {
let (dataset, metric) = extract_source_info(before)?;
Some(CompletionResult::Tag {
span,
dataset,
metric,
})
} else if words.len() > 2 {
if last == "is" {
return Some(CompletionResult::Keywords {
span,
options: vec![
KeywordItem {
label: "string",
apply: Some("string "),
info: "String type",
},
KeywordItem {
label: "int",
apply: Some("int "),
info: "Integer type",
},
KeywordItem {
label: "float",
apply: Some("float "),
info: "Float type",
},
KeywordItem {
label: "bool",
apply: Some("bool "),
info: "Boolean type",
},
],
});
}
if matches!(last, "==" | "!=" | "<" | ">" | "<=" | ">=") {
return suggest_filter_value_params(span, last, params, active_gate);
}
Some(CompletionResult::Keywords {
span,
options: vec![
KeywordItem {
label: "and",
apply: Some("and "),
info: "Logical AND",
},
KeywordItem {
label: "or",
apply: Some("or "),
info: "Logical OR",
},
KeywordItem {
label: "not",
apply: Some("not "),
info: "Logical NOT",
},
],
})
} else {
Some(CompletionResult::Keywords {
span,
options: vec![
KeywordItem {
label: "==",
apply: Some("== "),
info: "Equal",
},
KeywordItem {
label: "!=",
apply: Some("!= "),
info: "Not equal",
},
KeywordItem {
label: "<",
apply: Some("< "),
info: "Less than",
},
KeywordItem {
label: ">",
apply: Some("> "),
info: "Greater than",
},
KeywordItem {
label: "<=",
apply: Some("<= "),
info: "Less than or equal",
},
KeywordItem {
label: ">=",
apply: Some(">= "),
info: "Greater than or equal",
},
KeywordItem {
label: "is",
apply: Some("is "),
info: "Type check",
},
],
})
}
}
fn suggest_filter_value_params(
span: Span,
op: &str,
params: &[ParamItem],
active_gate: Option<&str>,
) -> Option<CompletionResult> {
suggest_params(span, params, active_gate, |typ| match typ {
ParamType::String | ParamType::Bool | ParamType::Int | ParamType::Float => true,
ParamType::Regex => matches!(op, "==" | "!="),
_ => false,
})
}
fn is_preamble_only(text: &str) -> bool {
let mut has_preamble = false;
for line in text.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if trimmed.starts_with("//") || trimmed.starts_with("set ") || trimmed.starts_with("param ")
{
has_preamble = true;
continue;
}
return false;
}
has_preamble
}
fn suggest_for_preamble(text: &str, partial: &str, span: Span) -> Option<CompletionResult> {
if find_last_pipe(text).is_some() {
return None;
}
let stmt = text.rsplit(['\n', ';']).next().unwrap_or(text).trim_start();
let lower = partial.to_ascii_lowercase();
let matches_preamble_kw =
!lower.is_empty() && ["param", "set"].iter().any(|kw| kw.starts_with(&lower));
if stmt.is_empty() && matches_preamble_kw && is_preamble_position(text) {
return Some(CompletionResult::Keywords {
span,
options: vec![
KeywordItem {
label: "param",
apply: Some("param "),
info: "Declare a query parameter",
},
KeywordItem {
label: "set",
apply: Some("set "),
info: "Set a query option",
},
],
});
}
if stmt == "param" || stmt.starts_with("param ") {
let rest = stmt["param".len()..].trim_start();
if let Some((name, _)) = rest.split_once(':')
&& name.trim().starts_with('$')
{
return Some(CompletionResult::Keywords {
span,
options: PARAM_TYPE_KEYWORDS.to_vec(),
});
}
return Some(CompletionResult::Keywords {
span,
options: vec![],
});
}
if stmt == "set" || stmt.starts_with("set ") {
return Some(CompletionResult::Keywords {
span,
options: vec![],
});
}
None
}
fn is_preamble_position(text: &str) -> bool {
for line in text.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if trimmed.starts_with("//") || trimmed.starts_with("set ") || trimmed.starts_with("param ")
{
continue;
}
return false;
}
true
}
const PARAM_TYPE_KEYWORDS: [KeywordItem; 13] = [
KeywordItem {
label: "Dataset",
apply: Some("Dataset;\n"),
info: "Parameter type",
},
KeywordItem {
label: "Metric",
apply: Some("Metric;\n"),
info: "Parameter type",
},
KeywordItem {
label: "Duration",
apply: Some("Duration;\n"),
info: "Parameter type",
},
KeywordItem {
label: "string",
apply: Some("string;\n"),
info: "Parameter type",
},
KeywordItem {
label: "int",
apply: Some("int;\n"),
info: "Parameter type",
},
KeywordItem {
label: "float",
apply: Some("float;\n"),
info: "Parameter type",
},
KeywordItem {
label: "bool",
apply: Some("bool;\n"),
info: "Parameter type",
},
KeywordItem {
label: "Regex",
apply: Some("Regex;\n"),
info: "Parameter type",
},
KeywordItem {
label: "Option<string>",
apply: Some("Option<string>;\n"),
info: "Optional parameter type for ifdef filters",
},
KeywordItem {
label: "Option<int>",
apply: Some("Option<int>;\n"),
info: "Optional parameter type for ifdef filters",
},
KeywordItem {
label: "Option<float>",
apply: Some("Option<float>;\n"),
info: "Optional parameter type for ifdef filters",
},
KeywordItem {
label: "Option<bool>",
apply: Some("Option<bool>;\n"),
info: "Optional parameter type for ifdef filters",
},
KeywordItem {
label: "Option<Regex>",
apply: Some("Option<Regex>;\n"),
info: "Optional parameter type for ifdef filters",
},
];
fn suggest_for_source(
text: &str,
partial: &str,
span: Span,
params: &[ParamItem],
) -> Option<CompletionResult> {
if find_last_pipe(text).is_some() {
return None;
}
if partial.is_empty() && is_preamble_only(text) {
return None;
}
if let Some(colon_idx) = partial.find(':') {
let dataset_raw = &partial[..colon_idx];
if dataset_raw.is_empty() {
return None;
}
let metric_part = &partial[colon_idx + 1..];
if metric_part.starts_with('$') {
return suggest_params(
Span::new(span.from + colon_idx + 1, span.to),
params,
None,
|t| t == ParamType::Metric,
);
}
let dataset = dataset_raw
.strip_prefix('`')
.and_then(|s| s.strip_suffix('`'))
.unwrap_or(dataset_raw);
let backtick_offset = usize::from(metric_part.starts_with('`'));
Some(CompletionResult::Metric {
span: Span::new(span.from + colon_idx + 1 + backtick_offset, span.to),
dataset: dataset.to_string(),
})
} else if partial.starts_with('$') {
suggest_params(span, params, None, |t| t == ParamType::Dataset)
} else {
let backtick_offset = usize::from(partial.starts_with('`'));
Some(CompletionResult::Dataset {
span: Span::new(span.from + backtick_offset, span.to),
})
}
}
#[cfg(test)]
mod tests;