Skip to main content

lemon/
services.rs

1//! Editor language services for the Lemon DSL — pure, I/O-free, editor-agnostic.
2//!
3//! This module turns the lexer + op catalog (the same single source of truth the
4//! parser and schema generator consume) into the three primitives every code
5//! editor needs:
6//!
7//! - [`diagnostics`] — parse/lex errors and the semantic lints from
8//!   [`crate::lint`] (unused `let`s; unknown series when a series list is given),
9//!   as ranged [`Diagnostic`]s. The linter is the single diagnostics source, so
10//!   the editor squiggles match `lemon lint` exactly.
11//! - [`hover`] — the signature and description of the op / operator / series
12//!   under the cursor, rendered as Markdown.
13//! - [`completions`] — op names, keyword-argument names for the enclosing call,
14//!   `let`-bound names in scope, known series, and keyword literals.
15//!
16//! Everything here is a pure function of `(source, position)`. The same core
17//! backs both the WASM boundary (`lemon-wasm`, for the in-browser editor) and
18//! the native language server (`lemon-lsp`, over `tower-lsp`), so hover text and
19//! completions can never drift between the two surfaces.
20//!
21//! # Positions
22//!
23//! All positions are **1-based** `(line, col)`, matching [`crate::ParseError`]
24//! and the lexer: `col` is the column of a character, and a cursor "after" the
25//! last typed character sits at `len + 1`. Ranges are half-open — `end_col` is
26//! one past the last covered column. Editors that speak 0-based positions (LSP)
27//! convert at their boundary.
28
29use crate::dsl::lex::{lex, Token, TokenKind};
30use crate::meta::{function_ops, OpInfo};
31
32/// Diagnostic severity. Parse/lex errors are [`Severity::Error`]; semantic lints
33/// from [`crate::lint`] are [`Severity::Warning`].
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum Severity {
36    Error,
37    Warning,
38}
39
40/// A ranged diagnostic: `[ (line, col) .. (end_line, end_col) )`, 1-based, with
41/// `end_col` exclusive.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct Diagnostic {
44    pub line: usize,
45    pub col: usize,
46    pub end_line: usize,
47    pub end_col: usize,
48    pub severity: Severity,
49    pub message: String,
50}
51
52/// What a [`CompletionItem`] refers to — lets the editor pick an icon.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
54pub enum CompletionKind {
55    /// A callable op (`sma`, `rank`, …).
56    Function,
57    /// A keyword-argument name for the enclosing call (`ascending`).
58    Field,
59    /// A `let`-bound name in scope.
60    Variable,
61    /// A known input data series (`close`, `pe`, …).
62    Series,
63    /// A language keyword / literal (`let`, `and`, `true`, …).
64    Keyword,
65}
66
67impl CompletionKind {
68    /// Lowercase tag used in the JSON boundary.
69    pub fn as_str(self) -> &'static str {
70        match self {
71            CompletionKind::Function => "function",
72            CompletionKind::Field => "field",
73            CompletionKind::Variable => "variable",
74            CompletionKind::Series => "series",
75            CompletionKind::Keyword => "keyword",
76        }
77    }
78}
79
80/// One completion candidate. `insert_text` is the text to insert (equal to
81/// `label` for everything except keyword args, which insert `name=`).
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct CompletionItem {
84    pub label: String,
85    pub kind: CompletionKind,
86    /// A short, one-line signature or category (shown to the right of the label).
87    pub detail: String,
88    /// Longer Markdown documentation (the op description, for functions).
89    pub documentation: String,
90    pub insert_text: String,
91}
92
93/// The hover card for the token under the cursor: a range to highlight plus the
94/// Markdown body.
95#[derive(Debug, Clone, PartialEq, Eq)]
96pub struct HoverInfo {
97    pub line: usize,
98    pub col: usize,
99    pub end_line: usize,
100    pub end_col: usize,
101    pub markdown: String,
102}
103
104/// Input series the engine always provides from price data. Fundamentals are
105/// open-ended (any identifier is accepted), so this list drives completion only.
106pub const PRICE_SERIES: &[&str] = &["open", "high", "low", "close", "volume"];
107
108/// A curated set of common fundamental series, offered as completions purely as
109/// a convenience. Not exhaustive and not authoritative — the engine's data
110/// context is the source of truth.
111pub const COMMON_FUNDAMENTALS: &[&str] = &[
112    "pe",
113    "pb",
114    "ps",
115    "roe",
116    "roa",
117    "eps",
118    "market_cap",
119    "revenue_growth",
120    "dividend_yield",
121];
122
123/// Reserved words that are never data series or op calls.
124const KEYWORDS: &[&str] = &["let", "and", "or", "true", "false"];
125
126// ---------------------------------------------------------------------------
127// Diagnostics
128// ---------------------------------------------------------------------------
129
130/// Compute diagnostics for `src`: the (single) parse or lex error if the source
131/// is invalid, otherwise the semantic lints from [`crate::lint`] as ranged
132/// warnings.
133///
134/// `known_series` is the engine's list of valid data-series names. Pass it to
135/// enable the unknown-series check (typo'd `Data` leaves, with did-you-mean
136/// suggestions); pass `None` to skip it (unused-`let` warnings still fire). The
137/// LSP/editor has no series list unless configured, so it defaults to `None`.
138pub fn diagnostics(src: &str, known_series: Option<&[String]>) -> Vec<Diagnostic> {
139    // A lex failure is a hard stop — there are no tokens to lint or span.
140    let toks = match lex(src) {
141        Ok(t) => t,
142        Err(e) => {
143            return vec![Diagnostic {
144                line: e.line,
145                col: e.col,
146                end_line: e.line,
147                end_col: e.col + 1,
148                severity: Severity::Error,
149                message: e.message,
150            }]
151        }
152    };
153
154    // The linter is the diagnostics source: it returns the parse error (if any)
155    // or the semantic lints for a clean parse.
156    match crate::lint(src, known_series) {
157        Err(e) => {
158            // A parse failure spans the token at its position when one exists, so
159            // the squiggle covers the whole word rather than a single column.
160            let (end_line, end_col) = token_at(&toks, e.line, e.col)
161                .map(token_end)
162                .unwrap_or((e.line, e.col + 1));
163            vec![Diagnostic {
164                line: e.line,
165                col: e.col,
166                end_line,
167                end_col,
168                severity: Severity::Error,
169                message: e.message,
170            }]
171        }
172        Ok(lints) => lints
173            .into_iter()
174            .map(|l| {
175                let (end_line, end_col) = token_at(&toks, l.line, l.col)
176                    .map(token_end)
177                    .unwrap_or((l.line, l.col + 1));
178                Diagnostic {
179                    line: l.line,
180                    col: l.col,
181                    end_line,
182                    end_col,
183                    severity: Severity::Warning,
184                    message: l.message,
185                }
186            })
187            .collect(),
188    }
189}
190
191// ---------------------------------------------------------------------------
192// Hover
193// ---------------------------------------------------------------------------
194
195/// Markdown hover for the token at 1-based `(line, col)`, or `None` when there is
196/// nothing documented there (whitespace, punctuation, numbers, unknown series).
197pub fn hover(src: &str, line: usize, col: usize) -> Option<HoverInfo> {
198    let toks = lex(src).ok()?;
199    let t = token_at(&toks, line, col)?;
200    let (end_line, end_col) = token_end(t);
201
202    let markdown = match &t.kind {
203        TokenKind::Ident(name) => {
204            if let Some(op) = lookup_op(name) {
205                op_markdown(&op)
206            } else if let Some(kw) = keyword_markdown(name) {
207                kw
208            } else if PRICE_SERIES.contains(&name.as_str()) {
209                format!("**`{name}`** — input data series (price).")
210            } else {
211                // Any other identifier is a data-series reference.
212                format!("**`{name}`** — data series reference.")
213            }
214        }
215        TokenKind::Op(sym) => binop_markdown(sym)?,
216        TokenKind::Let => keyword_markdown("let")?,
217        _ => return None,
218    };
219
220    Some(HoverInfo {
221        line: t.line,
222        col: t.col,
223        end_line,
224        end_col,
225        markdown,
226    })
227}
228
229/// The full hover body for a callable op: its signature, description, and aliases.
230fn op_markdown(op: &OpInfo) -> String {
231    let mut s = format!("```lemon\n{}\n```\n\n{}", signature(op), op.description);
232    if !op.aliases.is_empty() {
233        let aliases = op
234            .aliases
235            .iter()
236            .map(|a| format!("`{a}`"))
237            .collect::<Vec<_>>()
238            .join(", ");
239        s.push_str(&format!("\n\n*Aliases: {aliases}*"));
240    }
241    s
242}
243
244/// A one-line signature: `sma(of, n)`, with optional fields marked `?` and a
245/// trailing `= default` where the catalog records one.
246fn signature(op: &OpInfo) -> String {
247    let params: Vec<String> = op
248        .fields
249        .iter()
250        .map(|f| {
251            let opt = if f.required { "" } else { "?" };
252            match &f.default {
253                Some(d) => format!("{}{opt}={d}", f.name),
254                None => format!("{}{opt}", f.name),
255            }
256        })
257        .collect();
258    format!("{}({})", op.name, params.join(", "))
259}
260
261fn keyword_markdown(word: &str) -> Option<String> {
262    let body = match word {
263        "let" => "**`let`** — bind a name to a sub-expression: `let ma = sma(close, 20)`. Bindings are inlined at parse time.",
264        "and" => "**`and`** — logical AND. Yields `1.0` where both operands are truthy, else `0.0`.",
265        "or" => "**`or`** — logical OR. Yields `1.0` where either operand is truthy, else `0.0`.",
266        "true" => "**`true`** — boolean literal (keyword-argument values only).",
267        "false" => "**`false`** — boolean literal (keyword-argument values only).",
268        _ => return None,
269    };
270    Some(body.to_string())
271}
272
273fn binop_markdown(sym: &str) -> Option<String> {
274    let desc = match sym {
275        ">" => "greater-than",
276        "<" => "less-than",
277        ">=" => "greater-than-or-equal",
278        "<=" => "less-than-or-equal",
279        "+" => "addition",
280        "-" => "subtraction (or unary negation)",
281        "*" => "multiplication",
282        "/" => "division",
283        _ => return None,
284    };
285    Some(format!(
286        "**`{sym}`** — {desc}. Comparisons output `1.0`/`0.0`."
287    ))
288}
289
290// ---------------------------------------------------------------------------
291// Completions
292// ---------------------------------------------------------------------------
293
294/// Completion candidates for the cursor at 1-based `(line, col)`.
295///
296/// The list is filtered by the identifier prefix under the cursor and ordered by
297/// relevance: keyword arguments for the enclosing call first (when inside a
298/// call), then `let`-bound names, ops, series, and keyword literals.
299pub fn completions(src: &str, line: usize, col: usize) -> Vec<CompletionItem> {
300    let toks = match lex(src) {
301        Ok(t) => t,
302        // Even on a lex error, offer the static vocabulary so completion still
303        // works while the user is mid-edit.
304        Err(_) => return filter_items(static_items(&[]), ""),
305    };
306
307    let prefix = prefix_at(&toks, line, col);
308    let before = tokens_before(&toks, line, col);
309    let enclosing = enclosing_call(&before);
310    let bound = let_bound_names(&before);
311
312    let mut items = Vec::new();
313
314    // Keyword-argument names for the call we're inside, most relevant first.
315    if let Some(op) = enclosing.as_deref().and_then(lookup_op) {
316        for f in &op.fields {
317            items.push(CompletionItem {
318                label: f.name.to_string(),
319                kind: CompletionKind::Field,
320                detail: format!("{} argument ({})", op.name, f.kind),
321                documentation: String::new(),
322                insert_text: format!("{}=", f.name),
323            });
324        }
325    }
326
327    // `let`-bound names visible at the cursor.
328    for name in bound {
329        items.push(CompletionItem {
330            label: name.clone(),
331            kind: CompletionKind::Variable,
332            detail: "let-bound".to_string(),
333            documentation: String::new(),
334            insert_text: name,
335        });
336    }
337
338    items.extend(static_items(&[]));
339    filter_items(items, &prefix)
340}
341
342/// The vocabulary that is always available regardless of context: every op, the
343/// known series, and the keyword literals. `_extra` is reserved for future
344/// context-specific additions.
345fn static_items(_extra: &[&str]) -> Vec<CompletionItem> {
346    let mut items = Vec::new();
347
348    for op in function_ops() {
349        items.push(CompletionItem {
350            label: op.name.to_string(),
351            kind: CompletionKind::Function,
352            detail: signature(&op),
353            documentation: op.description.to_string(),
354            insert_text: op.name.to_string(),
355        });
356    }
357
358    for &s in PRICE_SERIES {
359        items.push(series_item(s, "price series"));
360    }
361    for &s in COMMON_FUNDAMENTALS {
362        items.push(series_item(s, "fundamental series"));
363    }
364    for &kw in KEYWORDS {
365        items.push(CompletionItem {
366            label: kw.to_string(),
367            kind: CompletionKind::Keyword,
368            detail: "keyword".to_string(),
369            documentation: String::new(),
370            insert_text: kw.to_string(),
371        });
372    }
373    items
374}
375
376fn series_item(name: &str, detail: &str) -> CompletionItem {
377    CompletionItem {
378        label: name.to_string(),
379        kind: CompletionKind::Series,
380        detail: detail.to_string(),
381        documentation: String::new(),
382        insert_text: name.to_string(),
383    }
384}
385
386/// Keep only items whose label starts with `prefix` (case-insensitive). An empty
387/// prefix keeps everything. De-duplicates by `(label, kind)` so a series that is
388/// also offered as a keyword argument does not appear twice for the same reason.
389fn filter_items(items: Vec<CompletionItem>, prefix: &str) -> Vec<CompletionItem> {
390    let pfx = prefix.to_ascii_lowercase();
391    let mut seen = std::collections::HashSet::new();
392    items
393        .into_iter()
394        .filter(|it| it.label.to_ascii_lowercase().starts_with(&pfx))
395        .filter(|it| seen.insert((it.label.clone(), it.kind)))
396        .collect()
397}
398
399// ---------------------------------------------------------------------------
400// Shared token helpers
401// ---------------------------------------------------------------------------
402
403/// Look up a callable op by its canonical name or any alias.
404fn lookup_op(name: &str) -> Option<OpInfo> {
405    function_ops()
406        .into_iter()
407        .find(|o| o.name == name || o.aliases.contains(&name))
408}
409
410/// The `let`-bound names introduced anywhere in `toks` (the token after each
411/// `let`). Used both to seed completion and to exclude bindings from the typo
412/// lint.
413fn let_bound_names(toks: &[Token]) -> Vec<String> {
414    let mut out = Vec::new();
415    for (i, t) in toks.iter().enumerate() {
416        if t.kind == TokenKind::Let {
417            if let Some(Token {
418                kind: TokenKind::Ident(name),
419                ..
420            }) = toks.get(i + 1)
421            {
422                if !out.contains(name) {
423                    out.push(name.clone());
424                }
425            }
426        }
427    }
428    out
429}
430
431/// The op name of the innermost call whose argument list the cursor sits in, if
432/// any. Walks the bracket stack: `name(` opens a call frame, `(`/`[` open a
433/// non-call frame, and each closer pops. `toks` must already be truncated to the
434/// tokens before the cursor (see [`tokens_before`]).
435fn enclosing_call(toks: &[Token]) -> Option<String> {
436    let mut stack: Vec<Option<String>> = Vec::new();
437    for (i, t) in toks.iter().enumerate() {
438        match &t.kind {
439            TokenKind::LParen => {
440                let name = match i.checked_sub(1).and_then(|p| toks.get(p)) {
441                    Some(Token {
442                        kind: TokenKind::Ident(n),
443                        ..
444                    }) => Some(n.clone()),
445                    _ => None,
446                };
447                stack.push(name);
448            }
449            TokenKind::LBracket => stack.push(None),
450            TokenKind::RParen | TokenKind::RBracket => {
451                stack.pop();
452            }
453            _ => {}
454        }
455    }
456    stack.into_iter().rev().flatten().next()
457}
458
459/// The identifier prefix the cursor is currently inside/just after, or `""`.
460fn prefix_at(toks: &[Token], line: usize, col: usize) -> String {
461    for t in toks {
462        if let TokenKind::Ident(name) = &t.kind {
463            let len = name.chars().count();
464            if t.line == line && t.col <= col && col <= t.col + len {
465                let take = col - t.col;
466                return name.chars().take(take).collect();
467            }
468        }
469    }
470    String::new()
471}
472
473/// The tokens strictly before the cursor, in order (dropping the `Eof`). A token
474/// counts as "before" when it starts before the cursor position.
475fn tokens_before(toks: &[Token], line: usize, col: usize) -> Vec<Token> {
476    toks.iter()
477        .filter(|t| t.kind != TokenKind::Eof)
478        .filter(|t| t.line < line || (t.line == line && t.col < col))
479        .cloned()
480        .collect()
481}
482
483/// The token whose span covers 1-based `(line, col)`, if any. `Eof` never
484/// matches. The cursor is treated as covered when `col` is within `[start, end)`.
485fn token_at(toks: &[Token], line: usize, col: usize) -> Option<&Token> {
486    toks.iter().find(|t| {
487        if t.kind == TokenKind::Eof || t.line != line {
488            return false;
489        }
490        let (_, end_col) = token_end(t);
491        t.col <= col && col < end_col
492    })
493}
494
495/// The exclusive end `(line, col)` of a token's source span. Multi-line tokens do
496/// not occur in this grammar, so the end line always equals the start line.
497fn token_end(t: &Token) -> (usize, usize) {
498    let len = match &t.kind {
499        TokenKind::Ident(s) | TokenKind::Op(s) => s.chars().count(),
500        TokenKind::Str(s) => s.chars().count() + 2, // surrounding quotes
501        TokenKind::Num(_) => 1,                     // width unknown post-lex; a single-column caret
502        TokenKind::Let => 3,
503        TokenKind::LParen
504        | TokenKind::RParen
505        | TokenKind::LBracket
506        | TokenKind::RBracket
507        | TokenKind::Comma
508        | TokenKind::Eq => 1,
509        TokenKind::Eof => 0,
510    };
511    (t.line, t.col + len)
512}
513
514// ---------------------------------------------------------------------------
515// Semantic tokens (syntax highlighting)
516// ---------------------------------------------------------------------------
517
518/// A syntax-highlight classification for a run of source. This is the single
519/// source of truth for lemon highlighting: every editor surface (the in-browser
520/// CodeMirror playground, `citrus-fund`, an LSP semantic-tokens provider) colours
521/// from [`tokens`], so highlighting can never drift from the lexer.
522#[derive(Debug, Clone, Copy, PartialEq, Eq)]
523pub enum TokenType {
524    Comment,
525    Number,
526    Str,
527    Keyword,
528    Function,
529    Parameter,
530    Series,
531    Operator,
532    Punctuation,
533}
534
535impl TokenType {
536    /// Lowercase tag used in the JSON boundary and as an editor theme key.
537    pub fn as_str(self) -> &'static str {
538        match self {
539            TokenType::Comment => "comment",
540            TokenType::Number => "number",
541            TokenType::Str => "string",
542            TokenType::Keyword => "keyword",
543            TokenType::Function => "function",
544            TokenType::Parameter => "parameter",
545            TokenType::Series => "series",
546            TokenType::Operator => "operator",
547            TokenType::Punctuation => "punctuation",
548        }
549    }
550}
551
552/// A classified source span, `[ (line, col) .. (end_line, end_col) )`, 1-based
553/// with `end_col` exclusive — same convention as [`Diagnostic`].
554#[derive(Debug, Clone, PartialEq, Eq)]
555pub struct SemanticToken {
556    pub line: usize,
557    pub col: usize,
558    pub end_line: usize,
559    pub end_col: usize,
560    pub token_type: TokenType,
561}
562
563/// Classify every token in `src` for syntax highlighting.
564///
565/// Built on the parser's own [`lex`], so token boundaries and kinds match the
566/// language exactly. Identifiers are classified structurally — `name(` is a
567/// function call, `name=` a keyword argument, `and`/`or`/`not`/`true`/`false`
568/// are keywords, and anything else is a series reference — so no op list is
569/// hard-coded and nothing drifts when ops are added. The only rules re-derived
570/// here are the ones the lexer does not preserve: numeric span *width* (the
571/// parsed `f64` loses the original spelling like `1_000`/`5e8`) and line
572/// comments (which the lexer discards). Returns tokens in source order; on a lex
573/// error (e.g. an unterminated string mid-edit) returns just the comment spans.
574pub fn tokens(src: &str) -> Vec<SemanticToken> {
575    let lines: Vec<Vec<char>> = src.split('\n').map(|l| l.chars().collect()).collect();
576    let mut out = comment_spans(&lines);
577
578    if let Ok(toks) = lex(src) {
579        for (i, t) in toks.iter().enumerate() {
580            let token_type = match &t.kind {
581                TokenKind::Eof => continue,
582                TokenKind::Num(_) => TokenType::Number,
583                TokenKind::Str(_) => TokenType::Str,
584                TokenKind::Let => TokenType::Keyword,
585                TokenKind::Op(_) | TokenKind::Eq => TokenType::Operator,
586                TokenKind::LParen
587                | TokenKind::RParen
588                | TokenKind::LBracket
589                | TokenKind::RBracket
590                | TokenKind::Comma => TokenType::Punctuation,
591                TokenKind::Ident(s) => classify_ident(s, toks.get(i + 1)),
592            };
593            // Reuse `token_end` for every kind whose width the lexer preserves;
594            // numbers are the one exception, measured back off the source.
595            let (end_line, end_col) = match &t.kind {
596                TokenKind::Num(_) => (t.line, t.col + number_len(&lines, t.line, t.col)),
597                _ => token_end(t),
598            };
599            out.push(SemanticToken {
600                line: t.line,
601                col: t.col,
602                end_line,
603                end_col,
604                token_type,
605            });
606        }
607    }
608
609    out.sort_by_key(|t| (t.line, t.col));
610    out
611}
612
613/// Structural classification of a bareword: reserved words first, then by what
614/// follows — `name(` → call, `name=` → keyword argument, otherwise a series.
615fn classify_ident(s: &str, next: Option<&Token>) -> TokenType {
616    if matches!(s, "and" | "or" | "not" | "true" | "false") {
617        return TokenType::Keyword;
618    }
619    match next.map(|t| &t.kind) {
620        Some(TokenKind::LParen) => TokenType::Function,
621        Some(TokenKind::Eq) => TokenType::Parameter,
622        _ => TokenType::Series,
623    }
624}
625
626/// Character width of the numeric literal starting at 1-based `(line, col)`,
627/// mirroring the digit / `_` / `.` / exponent run the lexer accepts.
628fn number_len(lines: &[Vec<char>], line: usize, col: usize) -> usize {
629    let Some(row) = lines.get(line - 1) else {
630        return 1;
631    };
632    let start = col - 1;
633    let mut j = start;
634    while j < row.len() {
635        let d = row[j];
636        if d.is_ascii_digit() || d == '_' || d == '.' {
637            j += 1;
638        } else if (d == 'e' || d == 'E')
639            && j + 1 < row.len()
640            && (row[j + 1].is_ascii_digit()
641                || ((row[j + 1] == '+' || row[j + 1] == '-')
642                    && j + 2 < row.len()
643                    && row[j + 2].is_ascii_digit()))
644        {
645            j += 2; // the `e` and the sign or first exponent digit
646        } else {
647            break;
648        }
649    }
650    (j - start).max(1)
651}
652
653/// Line-comment spans (`# … EOL`) that are not inside a string literal, mirroring
654/// the lexer: `#` outside a string starts a comment, and `"` toggles string
655/// state (lemon strings have no escapes). `in_string` intentionally persists
656/// across lines to match the lexer's newline-tolerant string scan.
657fn comment_spans(lines: &[Vec<char>]) -> Vec<SemanticToken> {
658    let mut out = Vec::new();
659    let mut in_string = false;
660    for (li, row) in lines.iter().enumerate() {
661        let mut c = 0;
662        while c < row.len() {
663            match row[c] {
664                '"' => in_string = !in_string,
665                '#' if !in_string => {
666                    out.push(SemanticToken {
667                        line: li + 1,
668                        col: c + 1,
669                        end_line: li + 1,
670                        end_col: row.len() + 1,
671                        token_type: TokenType::Comment,
672                    });
673                    break; // the rest of the line is the comment
674                }
675                _ => {}
676            }
677            c += 1;
678        }
679    }
680    out
681}
682
683#[cfg(test)]
684mod tests {
685    use super::*;
686
687    // --- diagnostics --------------------------------------------------------
688
689    fn series(names: &[&str]) -> Vec<String> {
690        names.iter().map(|s| s.to_string()).collect()
691    }
692
693    #[test]
694    fn clean_source_has_no_diagnostics() {
695        assert!(diagnostics("close > sma(close, 2)", None).is_empty());
696        // With a series list, valid series stay clean too.
697        assert!(diagnostics("close > sma(close, 2)", Some(&series(&["close"]))).is_empty());
698    }
699
700    #[test]
701    fn parse_error_becomes_a_ranged_error_diagnostic() {
702        let diags = diagnostics("sma(close, 2", None);
703        assert_eq!(diags.len(), 1);
704        assert_eq!(diags[0].severity, Severity::Error);
705        assert!(diags[0].end_col > diags[0].col);
706    }
707
708    #[test]
709    fn lex_error_is_reported_without_panicking() {
710        let diags = diagnostics("close $ 1", None);
711        assert_eq!(diags.len(), 1);
712        assert_eq!(diags[0].severity, Severity::Error);
713        assert_eq!((diags[0].line, diags[0].col), (1, 7));
714    }
715
716    #[test]
717    fn parse_error_on_unknown_op_spans_the_word() {
718        let diags = diagnostics("frobnicate(close)", None);
719        assert_eq!(diags.len(), 1);
720        // The squiggle covers the whole `frobnicate` token, not one column.
721        assert_eq!(diags[0].col, 1);
722        assert_eq!(diags[0].end_col, 1 + "frobnicate".len());
723    }
724
725    #[test]
726    fn unknown_series_warning_is_ranged_when_a_series_list_is_given() {
727        let diags = diagnostics("clsoe > 1", Some(&series(&["close", "pe"])));
728        assert_eq!(diags.len(), 1);
729        assert_eq!(diags[0].severity, Severity::Warning);
730        assert!(diags[0].message.contains("close"), "{}", diags[0].message);
731        // The range spans the whole misspelled token `clsoe`.
732        assert_eq!((diags[0].col, diags[0].end_col), (1, 1 + "clsoe".len()));
733    }
734
735    #[test]
736    fn no_unknown_series_check_without_a_list() {
737        // A typo'd series is silent without the engine's series list.
738        assert!(diagnostics("clsoe > 1", None).is_empty());
739    }
740
741    #[test]
742    fn unused_let_binding_warns_even_without_a_series_list() {
743        let diags = diagnostics("let ma = sma(close, 20)\nclose > 1", None);
744        assert_eq!(diags.len(), 1);
745        assert_eq!(diags[0].severity, Severity::Warning);
746        assert!(diags[0].message.contains("unused let binding `ma`"));
747        // The range spans the binding name `ma`.
748        assert_eq!(diags[0].end_col, diags[0].col + "ma".len());
749    }
750
751    // --- hover --------------------------------------------------------------
752
753    #[test]
754    fn hover_on_op_shows_signature_and_description() {
755        let h = hover("close > sma(close, 2)", 1, 9).expect("hover on sma");
756        assert!(h.markdown.contains("sma(of, n)"), "{}", h.markdown);
757        assert!(h.markdown.contains("moving average"));
758        assert!(h.markdown.contains("Aliases"), "{}", h.markdown);
759        // Range covers the three-character `sma` token.
760        assert_eq!((h.line, h.col), (1, 9));
761        assert_eq!(h.end_col, 12);
762    }
763
764    #[test]
765    fn hover_on_operator_and_series_and_keyword() {
766        assert!(hover("close > 1", 1, 7)
767            .unwrap()
768            .markdown
769            .contains("greater"));
770        assert!(hover("close > 1", 1, 1).unwrap().markdown.contains("price"));
771        assert!(hover("let a = close\na > 1", 1, 1)
772            .unwrap()
773            .markdown
774            .contains("bind"));
775    }
776
777    #[test]
778    fn hover_on_unknown_series_and_nothing_are_distinguished() {
779        assert!(hover("roic > 1", 1, 1)
780            .unwrap()
781            .markdown
782            .contains("data series"));
783        // Whitespace / punctuation / numbers → no hover.
784        assert!(hover("close > 1", 1, 6).is_none()); // the space
785        assert!(hover("close > 1", 1, 9).is_none()); // the number `1`
786        assert!(hover("", 1, 1).is_none());
787    }
788
789    #[test]
790    fn hover_ignores_lex_errors() {
791        assert!(hover("close $", 1, 1).is_none());
792    }
793
794    #[test]
795    fn hover_on_op_without_aliases_omits_alias_line() {
796        // `ema` has no aliases — the alias line must be absent.
797        let h = hover("ema(close, 5)", 1, 1).unwrap();
798        assert!(h.markdown.contains("ema(of, n)"));
799        assert!(!h.markdown.contains("Aliases"), "{}", h.markdown);
800    }
801
802    #[test]
803    fn hover_on_boolean_literal_and_logical_words() {
804        assert!(hover("rank(close, ascending=true)", 1, 23)
805            .unwrap()
806            .markdown
807            .contains("boolean"));
808        assert!(hover("a and b", 1, 3).unwrap().markdown.contains("AND"));
809        assert!(hover("a or b", 1, 3).unwrap().markdown.contains("OR"));
810    }
811
812    #[test]
813    fn every_operator_symbol_has_hover_text() {
814        for sym in [">", "<", ">=", "<=", "+", "-", "*", "/"] {
815            assert!(binop_markdown(sym).is_some(), "no hover for `{sym}`");
816        }
817        assert!(binop_markdown("??").is_none());
818    }
819
820    #[test]
821    fn every_keyword_has_hover_text() {
822        for kw in ["let", "and", "or", "true", "false"] {
823            assert!(keyword_markdown(kw).is_some(), "no hover for `{kw}`");
824        }
825        assert!(keyword_markdown("close").is_none());
826    }
827
828    // --- completions --------------------------------------------------------
829
830    fn labels(items: &[CompletionItem]) -> Vec<&str> {
831        items.iter().map(|i| i.label.as_str()).collect()
832    }
833
834    #[test]
835    fn completes_op_names_by_prefix() {
836        let items = completions("sm", 1, 3);
837        let ls = labels(&items);
838        assert!(ls.contains(&"sma"));
839        assert!(!ls.contains(&"rank"), "prefix `sm` should exclude rank");
840    }
841
842    #[test]
843    fn empty_prefix_offers_the_whole_vocabulary() {
844        let items = completions("", 1, 1);
845        let ls = labels(&items);
846        assert!(ls.contains(&"sma"));
847        assert!(ls.contains(&"close"));
848        assert!(ls.contains(&"let"));
849    }
850
851    #[test]
852    fn inside_a_call_offers_keyword_arguments_first() {
853        // Cursor inside rank(...) — `ascending`/`pct` should be offered as fields.
854        let items = completions("rank(close, )", 1, 13);
855        let field = items
856            .iter()
857            .find(|i| i.label == "ascending")
858            .expect("ascending field offered");
859        assert_eq!(field.kind, CompletionKind::Field);
860        assert_eq!(field.insert_text, "ascending=");
861    }
862
863    #[test]
864    fn completes_let_bound_names() {
865        let src = "let ma = sma(close, 20)\nclose > m";
866        let items = completions(src, 2, 10);
867        let ma = items.iter().find(|i| i.label == "ma").expect("ma offered");
868        assert_eq!(ma.kind, CompletionKind::Variable);
869    }
870
871    #[test]
872    fn enclosing_call_handles_lists_closed_calls_and_leading_paren() {
873        // Inside a list literal nested in a call → still offers the call's fields.
874        let items = completions("neutralize(close, [pe, ", 1, 23);
875        assert!(items
876            .iter()
877            .any(|i| i.label == "by" && i.kind == CompletionKind::Field));
878
879        // After a fully-closed call → no enclosing call, just the vocabulary.
880        let items = completions("sma(close, 2) and cl", 1, 21);
881        assert!(labels(&items).contains(&"close"));
882        assert!(!items.iter().any(|i| i.kind == CompletionKind::Field));
883
884        // A leading `(` with no op before it must not panic (checked_sub).
885        let items = completions("(cl", 1, 4);
886        assert!(labels(&items).contains(&"close"));
887    }
888
889    #[test]
890    fn completion_survives_a_lex_error() {
891        // A stray `$` makes lexing fail; static vocabulary is still returned.
892        let items = completions("$sm", 1, 1);
893        assert!(!items.is_empty());
894        assert!(labels(&items).contains(&"sma"));
895    }
896
897    #[test]
898    fn no_duplicate_labels_of_the_same_kind() {
899        let items = completions("", 1, 1);
900        let mut seen = std::collections::HashSet::new();
901        for it in &items {
902            assert!(
903                seen.insert((it.label.clone(), it.kind)),
904                "duplicate: {} / {:?}",
905                it.label,
906                it.kind
907            );
908        }
909    }
910
911    // --- helpers ------------------------------------------------------------
912
913    #[test]
914    fn token_end_covers_every_kind_and_let_without_name() {
915        let toks = lex("let x = \"s\" >= 1 + (a) [ , ]").unwrap();
916        for t in &toks {
917            let (_, end) = token_end(t);
918            assert!(end >= t.col);
919        }
920        // A string span includes both surrounding quotes: `"s"` is three columns.
921        let str_tok = toks
922            .iter()
923            .find(|t| matches!(t.kind, TokenKind::Str(_)))
924            .unwrap();
925        assert_eq!(token_end(str_tok), (str_tok.line, str_tok.col + 3));
926        // Eof has zero width.
927        let eof = toks.last().unwrap();
928        assert_eq!(token_end(eof), (eof.line, eof.col));
929        // `let` with no following identifier introduces no binding and never panics.
930        assert!(let_bound_names(&lex("let").unwrap()).is_empty());
931    }
932
933    #[test]
934    fn completion_kind_tags_round_trip() {
935        assert_eq!(CompletionKind::Function.as_str(), "function");
936        assert_eq!(CompletionKind::Field.as_str(), "field");
937        assert_eq!(CompletionKind::Variable.as_str(), "variable");
938        assert_eq!(CompletionKind::Series.as_str(), "series");
939        assert_eq!(CompletionKind::Keyword.as_str(), "keyword");
940    }
941
942    // --- semantic tokens (highlighting) -------------------------------------
943
944    fn typed(src: &str) -> Vec<(&'static str, usize, usize, usize)> {
945        tokens(src)
946            .into_iter()
947            .map(|t| (t.token_type.as_str(), t.line, t.col, t.end_col))
948            .collect()
949    }
950
951    #[test]
952    fn classifies_call_series_number_and_punctuation() {
953        // is_largest( -> function; sma( -> function; close -> series; 2/3 -> number
954        assert_eq!(
955            typed("is_largest(sma(close, 2), 3)"),
956            vec![
957                ("function", 1, 1, 11),     // is_largest
958                ("punctuation", 1, 11, 12), // (
959                ("function", 1, 12, 15),    // sma
960                ("punctuation", 1, 15, 16), // (
961                ("series", 1, 16, 21),      // close
962                ("punctuation", 1, 21, 22), // ,
963                ("number", 1, 23, 24),      // 2
964                ("punctuation", 1, 24, 25), // )
965                ("punctuation", 1, 25, 26), // ,
966                ("number", 1, 27, 28),      // 3
967                ("punctuation", 1, 28, 29), // )
968            ]
969        );
970    }
971
972    #[test]
973    fn keyword_args_logic_and_operators() {
974        // ascending= -> parameter; true -> keyword; and/or -> keyword; > -> operator
975        let out = typed("rank(close, ascending=true) and close > sma(close, 2)");
976        assert!(out.contains(&("parameter", 1, 13, 22))); // ascending
977        assert!(out.contains(&("operator", 1, 22, 23))); // =
978        assert!(out.contains(&("keyword", 1, 23, 27))); // true
979        assert!(out.contains(&("keyword", 1, 29, 32))); // and
980        assert!(out.contains(&("operator", 1, 39, 40))); // >
981    }
982
983    #[test]
984    fn not_is_keyword_even_before_paren() {
985        // `not` must stay a keyword, not be misread as a call by the `(` lookahead.
986        let out = typed("not (close)");
987        assert_eq!(out[0], ("keyword", 1, 1, 4));
988    }
989
990    #[test]
991    fn numbers_keep_their_full_width() {
992        // Underscores and exponents are lost in the parsed f64; width is measured
993        // back off the source so the whole literal is covered.
994        assert_eq!(
995            typed("x >= 1_000_000"),
996            vec![
997                ("series", 1, 1, 2),   // x
998                ("operator", 1, 3, 5), // >=
999                ("number", 1, 6, 15),  // 1_000_000 (9 chars)
1000            ]
1001        );
1002        assert!(typed("5e8").contains(&("number", 1, 1, 4)));
1003    }
1004
1005    #[test]
1006    fn comments_are_spans_and_ignore_hashes_in_strings() {
1007        // A trailing comment is highlighted; a `#` inside a string is not.
1008        let out = typed("close # buy\n");
1009        assert!(out.contains(&("comment", 1, 7, 12)));
1010        let s = typed("in_sector(close, \"A#B\")");
1011        assert!(s.iter().all(|t| t.0 != "comment"));
1012    }
1013
1014    #[test]
1015    fn lex_error_still_yields_comment_spans() {
1016        // Unterminated string is a lex error; comments before it survive.
1017        let out = typed("# note\nsma(close, \"oops");
1018        assert_eq!(out.first(), Some(&("comment", 1, 1, 7)));
1019    }
1020
1021    #[test]
1022    fn token_type_tags_round_trip() {
1023        for (ty, tag) in [
1024            (TokenType::Comment, "comment"),
1025            (TokenType::Number, "number"),
1026            (TokenType::Str, "string"),
1027            (TokenType::Keyword, "keyword"),
1028            (TokenType::Function, "function"),
1029            (TokenType::Parameter, "parameter"),
1030            (TokenType::Series, "series"),
1031            (TokenType::Operator, "operator"),
1032            (TokenType::Punctuation, "punctuation"),
1033        ] {
1034            assert_eq!(ty.as_str(), tag);
1035        }
1036    }
1037}