use crate::dsl::lex::{lex, Token, TokenKind};
use crate::meta::{function_ops, OpInfo};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Severity {
Error,
Warning,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Diagnostic {
pub line: usize,
pub col: usize,
pub end_line: usize,
pub end_col: usize,
pub severity: Severity,
pub message: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum CompletionKind {
Function,
Field,
Variable,
Series,
Keyword,
}
impl CompletionKind {
pub fn as_str(self) -> &'static str {
match self {
CompletionKind::Function => "function",
CompletionKind::Field => "field",
CompletionKind::Variable => "variable",
CompletionKind::Series => "series",
CompletionKind::Keyword => "keyword",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CompletionItem {
pub label: String,
pub kind: CompletionKind,
pub detail: String,
pub documentation: String,
pub insert_text: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HoverInfo {
pub line: usize,
pub col: usize,
pub end_line: usize,
pub end_col: usize,
pub markdown: String,
}
pub const PRICE_SERIES: &[&str] = &["open", "high", "low", "close", "volume"];
pub const COMMON_FUNDAMENTALS: &[&str] = &[
"pe",
"pb",
"ps",
"roe",
"roa",
"eps",
"market_cap",
"revenue_growth",
"dividend_yield",
];
const KEYWORDS: &[&str] = &["let", "and", "or", "true", "false"];
pub fn diagnostics(src: &str, known_series: Option<&[String]>) -> Vec<Diagnostic> {
let toks = match lex(src) {
Ok(t) => t,
Err(e) => {
return vec![Diagnostic {
line: e.line,
col: e.col,
end_line: e.line,
end_col: e.col + 1,
severity: Severity::Error,
message: e.message,
}]
}
};
match crate::lint(src, known_series) {
Err(e) => {
let (end_line, end_col) = token_at(&toks, e.line, e.col)
.map(token_end)
.unwrap_or((e.line, e.col + 1));
vec![Diagnostic {
line: e.line,
col: e.col,
end_line,
end_col,
severity: Severity::Error,
message: e.message,
}]
}
Ok(lints) => lints
.into_iter()
.map(|l| {
let (end_line, end_col) = token_at(&toks, l.line, l.col)
.map(token_end)
.unwrap_or((l.line, l.col + 1));
Diagnostic {
line: l.line,
col: l.col,
end_line,
end_col,
severity: Severity::Warning,
message: l.message,
}
})
.collect(),
}
}
pub fn hover(src: &str, line: usize, col: usize) -> Option<HoverInfo> {
let toks = lex(src).ok()?;
let t = token_at(&toks, line, col)?;
let (end_line, end_col) = token_end(t);
let markdown = match &t.kind {
TokenKind::Ident(name) => {
if let Some(op) = lookup_op(name) {
op_markdown(&op)
} else if let Some(kw) = keyword_markdown(name) {
kw
} else if PRICE_SERIES.contains(&name.as_str()) {
format!("**`{name}`** — input data series (price).")
} else {
format!("**`{name}`** — data series reference.")
}
}
TokenKind::Op(sym) => binop_markdown(sym)?,
TokenKind::Let => keyword_markdown("let")?,
_ => return None,
};
Some(HoverInfo {
line: t.line,
col: t.col,
end_line,
end_col,
markdown,
})
}
fn op_markdown(op: &OpInfo) -> String {
let mut s = format!("```lemon\n{}\n```\n\n{}", signature(op), op.description);
if !op.aliases.is_empty() {
let aliases = op
.aliases
.iter()
.map(|a| format!("`{a}`"))
.collect::<Vec<_>>()
.join(", ");
s.push_str(&format!("\n\n*Aliases: {aliases}*"));
}
s
}
fn signature(op: &OpInfo) -> String {
let params: Vec<String> = op
.fields
.iter()
.map(|f| {
let opt = if f.required { "" } else { "?" };
match &f.default {
Some(d) => format!("{}{opt}={d}", f.name),
None => format!("{}{opt}", f.name),
}
})
.collect();
format!("{}({})", op.name, params.join(", "))
}
fn keyword_markdown(word: &str) -> Option<String> {
let body = match word {
"let" => "**`let`** — bind a name to a sub-expression: `let ma = sma(close, 20)`. Bindings are inlined at parse time.",
"and" => "**`and`** — logical AND. Yields `1.0` where both operands are truthy, else `0.0`.",
"or" => "**`or`** — logical OR. Yields `1.0` where either operand is truthy, else `0.0`.",
"true" => "**`true`** — boolean literal (keyword-argument values only).",
"false" => "**`false`** — boolean literal (keyword-argument values only).",
_ => return None,
};
Some(body.to_string())
}
fn binop_markdown(sym: &str) -> Option<String> {
let desc = match sym {
">" => "greater-than",
"<" => "less-than",
">=" => "greater-than-or-equal",
"<=" => "less-than-or-equal",
"+" => "addition",
"-" => "subtraction (or unary negation)",
"*" => "multiplication",
"/" => "division",
_ => return None,
};
Some(format!(
"**`{sym}`** — {desc}. Comparisons output `1.0`/`0.0`."
))
}
pub fn completions(src: &str, line: usize, col: usize) -> Vec<CompletionItem> {
let toks = match lex(src) {
Ok(t) => t,
Err(_) => return filter_items(static_items(&[]), ""),
};
let prefix = prefix_at(&toks, line, col);
let before = tokens_before(&toks, line, col);
let enclosing = enclosing_call(&before);
let bound = let_bound_names(&before);
let mut items = Vec::new();
if let Some(op) = enclosing.as_deref().and_then(lookup_op) {
for f in &op.fields {
items.push(CompletionItem {
label: f.name.to_string(),
kind: CompletionKind::Field,
detail: format!("{} argument ({})", op.name, f.kind),
documentation: String::new(),
insert_text: format!("{}=", f.name),
});
}
}
for name in bound {
items.push(CompletionItem {
label: name.clone(),
kind: CompletionKind::Variable,
detail: "let-bound".to_string(),
documentation: String::new(),
insert_text: name,
});
}
items.extend(static_items(&[]));
filter_items(items, &prefix)
}
fn static_items(_extra: &[&str]) -> Vec<CompletionItem> {
let mut items = Vec::new();
for op in function_ops() {
items.push(CompletionItem {
label: op.name.to_string(),
kind: CompletionKind::Function,
detail: signature(&op),
documentation: op.description.to_string(),
insert_text: op.name.to_string(),
});
}
for &s in PRICE_SERIES {
items.push(series_item(s, "price series"));
}
for &s in COMMON_FUNDAMENTALS {
items.push(series_item(s, "fundamental series"));
}
for &kw in KEYWORDS {
items.push(CompletionItem {
label: kw.to_string(),
kind: CompletionKind::Keyword,
detail: "keyword".to_string(),
documentation: String::new(),
insert_text: kw.to_string(),
});
}
items
}
fn series_item(name: &str, detail: &str) -> CompletionItem {
CompletionItem {
label: name.to_string(),
kind: CompletionKind::Series,
detail: detail.to_string(),
documentation: String::new(),
insert_text: name.to_string(),
}
}
fn filter_items(items: Vec<CompletionItem>, prefix: &str) -> Vec<CompletionItem> {
let pfx = prefix.to_ascii_lowercase();
let mut seen = std::collections::HashSet::new();
items
.into_iter()
.filter(|it| it.label.to_ascii_lowercase().starts_with(&pfx))
.filter(|it| seen.insert((it.label.clone(), it.kind)))
.collect()
}
fn lookup_op(name: &str) -> Option<OpInfo> {
function_ops()
.into_iter()
.find(|o| o.name == name || o.aliases.contains(&name))
}
fn let_bound_names(toks: &[Token]) -> Vec<String> {
let mut out = Vec::new();
for (i, t) in toks.iter().enumerate() {
if t.kind == TokenKind::Let {
if let Some(Token {
kind: TokenKind::Ident(name),
..
}) = toks.get(i + 1)
{
if !out.contains(name) {
out.push(name.clone());
}
}
}
}
out
}
fn enclosing_call(toks: &[Token]) -> Option<String> {
let mut stack: Vec<Option<String>> = Vec::new();
for (i, t) in toks.iter().enumerate() {
match &t.kind {
TokenKind::LParen => {
let name = match i.checked_sub(1).and_then(|p| toks.get(p)) {
Some(Token {
kind: TokenKind::Ident(n),
..
}) => Some(n.clone()),
_ => None,
};
stack.push(name);
}
TokenKind::LBracket => stack.push(None),
TokenKind::RParen | TokenKind::RBracket => {
stack.pop();
}
_ => {}
}
}
stack.into_iter().rev().flatten().next()
}
fn prefix_at(toks: &[Token], line: usize, col: usize) -> String {
for t in toks {
if let TokenKind::Ident(name) = &t.kind {
let len = name.chars().count();
if t.line == line && t.col <= col && col <= t.col + len {
let take = col - t.col;
return name.chars().take(take).collect();
}
}
}
String::new()
}
fn tokens_before(toks: &[Token], line: usize, col: usize) -> Vec<Token> {
toks.iter()
.filter(|t| t.kind != TokenKind::Eof)
.filter(|t| t.line < line || (t.line == line && t.col < col))
.cloned()
.collect()
}
fn token_at(toks: &[Token], line: usize, col: usize) -> Option<&Token> {
toks.iter().find(|t| {
if t.kind == TokenKind::Eof || t.line != line {
return false;
}
let (_, end_col) = token_end(t);
t.col <= col && col < end_col
})
}
fn token_end(t: &Token) -> (usize, usize) {
let len = match &t.kind {
TokenKind::Ident(s) | TokenKind::Op(s) => s.chars().count(),
TokenKind::Str(s) => s.chars().count() + 2, TokenKind::Num(_) => 1, TokenKind::Let => 3,
TokenKind::LParen
| TokenKind::RParen
| TokenKind::LBracket
| TokenKind::RBracket
| TokenKind::Comma
| TokenKind::Eq => 1,
TokenKind::Eof => 0,
};
(t.line, t.col + len)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TokenType {
Comment,
Number,
Str,
Keyword,
Function,
Parameter,
Series,
Operator,
Punctuation,
}
impl TokenType {
pub fn as_str(self) -> &'static str {
match self {
TokenType::Comment => "comment",
TokenType::Number => "number",
TokenType::Str => "string",
TokenType::Keyword => "keyword",
TokenType::Function => "function",
TokenType::Parameter => "parameter",
TokenType::Series => "series",
TokenType::Operator => "operator",
TokenType::Punctuation => "punctuation",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SemanticToken {
pub line: usize,
pub col: usize,
pub end_line: usize,
pub end_col: usize,
pub token_type: TokenType,
}
pub fn tokens(src: &str) -> Vec<SemanticToken> {
let lines: Vec<Vec<char>> = src.split('\n').map(|l| l.chars().collect()).collect();
let mut out = comment_spans(&lines);
if let Ok(toks) = lex(src) {
for (i, t) in toks.iter().enumerate() {
let token_type = match &t.kind {
TokenKind::Eof => continue,
TokenKind::Num(_) => TokenType::Number,
TokenKind::Str(_) => TokenType::Str,
TokenKind::Let => TokenType::Keyword,
TokenKind::Op(_) | TokenKind::Eq => TokenType::Operator,
TokenKind::LParen
| TokenKind::RParen
| TokenKind::LBracket
| TokenKind::RBracket
| TokenKind::Comma => TokenType::Punctuation,
TokenKind::Ident(s) => classify_ident(s, toks.get(i + 1)),
};
let (end_line, end_col) = match &t.kind {
TokenKind::Num(_) => (t.line, t.col + number_len(&lines, t.line, t.col)),
_ => token_end(t),
};
out.push(SemanticToken {
line: t.line,
col: t.col,
end_line,
end_col,
token_type,
});
}
}
out.sort_by_key(|t| (t.line, t.col));
out
}
fn classify_ident(s: &str, next: Option<&Token>) -> TokenType {
if matches!(s, "and" | "or" | "not" | "true" | "false") {
return TokenType::Keyword;
}
match next.map(|t| &t.kind) {
Some(TokenKind::LParen) => TokenType::Function,
Some(TokenKind::Eq) => TokenType::Parameter,
_ => TokenType::Series,
}
}
fn number_len(lines: &[Vec<char>], line: usize, col: usize) -> usize {
let Some(row) = lines.get(line - 1) else {
return 1;
};
let start = col - 1;
let mut j = start;
while j < row.len() {
let d = row[j];
if d.is_ascii_digit() || d == '_' || d == '.' {
j += 1;
} else if (d == 'e' || d == 'E')
&& j + 1 < row.len()
&& (row[j + 1].is_ascii_digit()
|| ((row[j + 1] == '+' || row[j + 1] == '-')
&& j + 2 < row.len()
&& row[j + 2].is_ascii_digit()))
{
j += 2; } else {
break;
}
}
(j - start).max(1)
}
fn comment_spans(lines: &[Vec<char>]) -> Vec<SemanticToken> {
let mut out = Vec::new();
let mut in_string = false;
for (li, row) in lines.iter().enumerate() {
let mut c = 0;
while c < row.len() {
match row[c] {
'"' => in_string = !in_string,
'#' if !in_string => {
out.push(SemanticToken {
line: li + 1,
col: c + 1,
end_line: li + 1,
end_col: row.len() + 1,
token_type: TokenType::Comment,
});
break; }
_ => {}
}
c += 1;
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn series(names: &[&str]) -> Vec<String> {
names.iter().map(|s| s.to_string()).collect()
}
#[test]
fn clean_source_has_no_diagnostics() {
assert!(diagnostics("close > sma(close, 2)", None).is_empty());
assert!(diagnostics("close > sma(close, 2)", Some(&series(&["close"]))).is_empty());
}
#[test]
fn parse_error_becomes_a_ranged_error_diagnostic() {
let diags = diagnostics("sma(close, 2", None);
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].severity, Severity::Error);
assert!(diags[0].end_col > diags[0].col);
}
#[test]
fn lex_error_is_reported_without_panicking() {
let diags = diagnostics("close $ 1", None);
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].severity, Severity::Error);
assert_eq!((diags[0].line, diags[0].col), (1, 7));
}
#[test]
fn parse_error_on_unknown_op_spans_the_word() {
let diags = diagnostics("frobnicate(close)", None);
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].col, 1);
assert_eq!(diags[0].end_col, 1 + "frobnicate".len());
}
#[test]
fn unknown_series_warning_is_ranged_when_a_series_list_is_given() {
let diags = diagnostics("clsoe > 1", Some(&series(&["close", "pe"])));
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].severity, Severity::Warning);
assert!(diags[0].message.contains("close"), "{}", diags[0].message);
assert_eq!((diags[0].col, diags[0].end_col), (1, 1 + "clsoe".len()));
}
#[test]
fn no_unknown_series_check_without_a_list() {
assert!(diagnostics("clsoe > 1", None).is_empty());
}
#[test]
fn unused_let_binding_warns_even_without_a_series_list() {
let diags = diagnostics("let ma = sma(close, 20)\nclose > 1", None);
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].severity, Severity::Warning);
assert!(diags[0].message.contains("unused let binding `ma`"));
assert_eq!(diags[0].end_col, diags[0].col + "ma".len());
}
#[test]
fn hover_on_op_shows_signature_and_description() {
let h = hover("close > sma(close, 2)", 1, 9).expect("hover on sma");
assert!(h.markdown.contains("sma(of, n)"), "{}", h.markdown);
assert!(h.markdown.contains("moving average"));
assert!(h.markdown.contains("Aliases"), "{}", h.markdown);
assert_eq!((h.line, h.col), (1, 9));
assert_eq!(h.end_col, 12);
}
#[test]
fn hover_on_operator_and_series_and_keyword() {
assert!(hover("close > 1", 1, 7)
.unwrap()
.markdown
.contains("greater"));
assert!(hover("close > 1", 1, 1).unwrap().markdown.contains("price"));
assert!(hover("let a = close\na > 1", 1, 1)
.unwrap()
.markdown
.contains("bind"));
}
#[test]
fn hover_on_unknown_series_and_nothing_are_distinguished() {
assert!(hover("roic > 1", 1, 1)
.unwrap()
.markdown
.contains("data series"));
assert!(hover("close > 1", 1, 6).is_none()); assert!(hover("close > 1", 1, 9).is_none()); assert!(hover("", 1, 1).is_none());
}
#[test]
fn hover_ignores_lex_errors() {
assert!(hover("close $", 1, 1).is_none());
}
#[test]
fn hover_on_op_without_aliases_omits_alias_line() {
let h = hover("ema(close, 5)", 1, 1).unwrap();
assert!(h.markdown.contains("ema(of, n)"));
assert!(!h.markdown.contains("Aliases"), "{}", h.markdown);
}
#[test]
fn hover_on_boolean_literal_and_logical_words() {
assert!(hover("rank(close, ascending=true)", 1, 23)
.unwrap()
.markdown
.contains("boolean"));
assert!(hover("a and b", 1, 3).unwrap().markdown.contains("AND"));
assert!(hover("a or b", 1, 3).unwrap().markdown.contains("OR"));
}
#[test]
fn every_operator_symbol_has_hover_text() {
for sym in [">", "<", ">=", "<=", "+", "-", "*", "/"] {
assert!(binop_markdown(sym).is_some(), "no hover for `{sym}`");
}
assert!(binop_markdown("??").is_none());
}
#[test]
fn every_keyword_has_hover_text() {
for kw in ["let", "and", "or", "true", "false"] {
assert!(keyword_markdown(kw).is_some(), "no hover for `{kw}`");
}
assert!(keyword_markdown("close").is_none());
}
fn labels(items: &[CompletionItem]) -> Vec<&str> {
items.iter().map(|i| i.label.as_str()).collect()
}
#[test]
fn completes_op_names_by_prefix() {
let items = completions("sm", 1, 3);
let ls = labels(&items);
assert!(ls.contains(&"sma"));
assert!(!ls.contains(&"rank"), "prefix `sm` should exclude rank");
}
#[test]
fn empty_prefix_offers_the_whole_vocabulary() {
let items = completions("", 1, 1);
let ls = labels(&items);
assert!(ls.contains(&"sma"));
assert!(ls.contains(&"close"));
assert!(ls.contains(&"let"));
}
#[test]
fn inside_a_call_offers_keyword_arguments_first() {
let items = completions("rank(close, )", 1, 13);
let field = items
.iter()
.find(|i| i.label == "ascending")
.expect("ascending field offered");
assert_eq!(field.kind, CompletionKind::Field);
assert_eq!(field.insert_text, "ascending=");
}
#[test]
fn completes_let_bound_names() {
let src = "let ma = sma(close, 20)\nclose > m";
let items = completions(src, 2, 10);
let ma = items.iter().find(|i| i.label == "ma").expect("ma offered");
assert_eq!(ma.kind, CompletionKind::Variable);
}
#[test]
fn enclosing_call_handles_lists_closed_calls_and_leading_paren() {
let items = completions("neutralize(close, [pe, ", 1, 23);
assert!(items
.iter()
.any(|i| i.label == "by" && i.kind == CompletionKind::Field));
let items = completions("sma(close, 2) and cl", 1, 21);
assert!(labels(&items).contains(&"close"));
assert!(!items.iter().any(|i| i.kind == CompletionKind::Field));
let items = completions("(cl", 1, 4);
assert!(labels(&items).contains(&"close"));
}
#[test]
fn completion_survives_a_lex_error() {
let items = completions("$sm", 1, 1);
assert!(!items.is_empty());
assert!(labels(&items).contains(&"sma"));
}
#[test]
fn no_duplicate_labels_of_the_same_kind() {
let items = completions("", 1, 1);
let mut seen = std::collections::HashSet::new();
for it in &items {
assert!(
seen.insert((it.label.clone(), it.kind)),
"duplicate: {} / {:?}",
it.label,
it.kind
);
}
}
#[test]
fn token_end_covers_every_kind_and_let_without_name() {
let toks = lex("let x = \"s\" >= 1 + (a) [ , ]").unwrap();
for t in &toks {
let (_, end) = token_end(t);
assert!(end >= t.col);
}
let str_tok = toks
.iter()
.find(|t| matches!(t.kind, TokenKind::Str(_)))
.unwrap();
assert_eq!(token_end(str_tok), (str_tok.line, str_tok.col + 3));
let eof = toks.last().unwrap();
assert_eq!(token_end(eof), (eof.line, eof.col));
assert!(let_bound_names(&lex("let").unwrap()).is_empty());
}
#[test]
fn completion_kind_tags_round_trip() {
assert_eq!(CompletionKind::Function.as_str(), "function");
assert_eq!(CompletionKind::Field.as_str(), "field");
assert_eq!(CompletionKind::Variable.as_str(), "variable");
assert_eq!(CompletionKind::Series.as_str(), "series");
assert_eq!(CompletionKind::Keyword.as_str(), "keyword");
}
fn typed(src: &str) -> Vec<(&'static str, usize, usize, usize)> {
tokens(src)
.into_iter()
.map(|t| (t.token_type.as_str(), t.line, t.col, t.end_col))
.collect()
}
#[test]
fn classifies_call_series_number_and_punctuation() {
assert_eq!(
typed("is_largest(sma(close, 2), 3)"),
vec![
("function", 1, 1, 11), ("punctuation", 1, 11, 12), ("function", 1, 12, 15), ("punctuation", 1, 15, 16), ("series", 1, 16, 21), ("punctuation", 1, 21, 22), ("number", 1, 23, 24), ("punctuation", 1, 24, 25), ("punctuation", 1, 25, 26), ("number", 1, 27, 28), ("punctuation", 1, 28, 29), ]
);
}
#[test]
fn keyword_args_logic_and_operators() {
let out = typed("rank(close, ascending=true) and close > sma(close, 2)");
assert!(out.contains(&("parameter", 1, 13, 22))); assert!(out.contains(&("operator", 1, 22, 23))); assert!(out.contains(&("keyword", 1, 23, 27))); assert!(out.contains(&("keyword", 1, 29, 32))); assert!(out.contains(&("operator", 1, 39, 40))); }
#[test]
fn not_is_keyword_even_before_paren() {
let out = typed("not (close)");
assert_eq!(out[0], ("keyword", 1, 1, 4));
}
#[test]
fn numbers_keep_their_full_width() {
assert_eq!(
typed("x >= 1_000_000"),
vec![
("series", 1, 1, 2), ("operator", 1, 3, 5), ("number", 1, 6, 15), ]
);
assert!(typed("5e8").contains(&("number", 1, 1, 4)));
}
#[test]
fn comments_are_spans_and_ignore_hashes_in_strings() {
let out = typed("close # buy\n");
assert!(out.contains(&("comment", 1, 7, 12)));
let s = typed("in_sector(close, \"A#B\")");
assert!(s.iter().all(|t| t.0 != "comment"));
}
#[test]
fn lex_error_still_yields_comment_spans() {
let out = typed("# note\nsma(close, \"oops");
assert_eq!(out.first(), Some(&("comment", 1, 1, 7)));
}
#[test]
fn token_type_tags_round_trip() {
for (ty, tag) in [
(TokenType::Comment, "comment"),
(TokenType::Number, "number"),
(TokenType::Str, "string"),
(TokenType::Keyword, "keyword"),
(TokenType::Function, "function"),
(TokenType::Parameter, "parameter"),
(TokenType::Series, "series"),
(TokenType::Operator, "operator"),
(TokenType::Punctuation, "punctuation"),
] {
assert_eq!(ty.as_str(), tag);
}
}
}