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#[cfg(test)]
515mod tests {
516    use super::*;
517
518    // --- diagnostics --------------------------------------------------------
519
520    fn series(names: &[&str]) -> Vec<String> {
521        names.iter().map(|s| s.to_string()).collect()
522    }
523
524    #[test]
525    fn clean_source_has_no_diagnostics() {
526        assert!(diagnostics("close > sma(close, 2)", None).is_empty());
527        // With a series list, valid series stay clean too.
528        assert!(diagnostics("close > sma(close, 2)", Some(&series(&["close"]))).is_empty());
529    }
530
531    #[test]
532    fn parse_error_becomes_a_ranged_error_diagnostic() {
533        let diags = diagnostics("sma(close, 2", None);
534        assert_eq!(diags.len(), 1);
535        assert_eq!(diags[0].severity, Severity::Error);
536        assert!(diags[0].end_col > diags[0].col);
537    }
538
539    #[test]
540    fn lex_error_is_reported_without_panicking() {
541        let diags = diagnostics("close $ 1", None);
542        assert_eq!(diags.len(), 1);
543        assert_eq!(diags[0].severity, Severity::Error);
544        assert_eq!((diags[0].line, diags[0].col), (1, 7));
545    }
546
547    #[test]
548    fn parse_error_on_unknown_op_spans_the_word() {
549        let diags = diagnostics("frobnicate(close)", None);
550        assert_eq!(diags.len(), 1);
551        // The squiggle covers the whole `frobnicate` token, not one column.
552        assert_eq!(diags[0].col, 1);
553        assert_eq!(diags[0].end_col, 1 + "frobnicate".len());
554    }
555
556    #[test]
557    fn unknown_series_warning_is_ranged_when_a_series_list_is_given() {
558        let diags = diagnostics("clsoe > 1", Some(&series(&["close", "pe"])));
559        assert_eq!(diags.len(), 1);
560        assert_eq!(diags[0].severity, Severity::Warning);
561        assert!(diags[0].message.contains("close"), "{}", diags[0].message);
562        // The range spans the whole misspelled token `clsoe`.
563        assert_eq!((diags[0].col, diags[0].end_col), (1, 1 + "clsoe".len()));
564    }
565
566    #[test]
567    fn no_unknown_series_check_without_a_list() {
568        // A typo'd series is silent without the engine's series list.
569        assert!(diagnostics("clsoe > 1", None).is_empty());
570    }
571
572    #[test]
573    fn unused_let_binding_warns_even_without_a_series_list() {
574        let diags = diagnostics("let ma = sma(close, 20)\nclose > 1", None);
575        assert_eq!(diags.len(), 1);
576        assert_eq!(diags[0].severity, Severity::Warning);
577        assert!(diags[0].message.contains("unused let binding `ma`"));
578        // The range spans the binding name `ma`.
579        assert_eq!(diags[0].end_col, diags[0].col + "ma".len());
580    }
581
582    // --- hover --------------------------------------------------------------
583
584    #[test]
585    fn hover_on_op_shows_signature_and_description() {
586        let h = hover("close > sma(close, 2)", 1, 9).expect("hover on sma");
587        assert!(h.markdown.contains("sma(of, n)"), "{}", h.markdown);
588        assert!(h.markdown.contains("moving average"));
589        assert!(h.markdown.contains("Aliases"), "{}", h.markdown);
590        // Range covers the three-character `sma` token.
591        assert_eq!((h.line, h.col), (1, 9));
592        assert_eq!(h.end_col, 12);
593    }
594
595    #[test]
596    fn hover_on_operator_and_series_and_keyword() {
597        assert!(hover("close > 1", 1, 7)
598            .unwrap()
599            .markdown
600            .contains("greater"));
601        assert!(hover("close > 1", 1, 1).unwrap().markdown.contains("price"));
602        assert!(hover("let a = close\na > 1", 1, 1)
603            .unwrap()
604            .markdown
605            .contains("bind"));
606    }
607
608    #[test]
609    fn hover_on_unknown_series_and_nothing_are_distinguished() {
610        assert!(hover("roic > 1", 1, 1)
611            .unwrap()
612            .markdown
613            .contains("data series"));
614        // Whitespace / punctuation / numbers → no hover.
615        assert!(hover("close > 1", 1, 6).is_none()); // the space
616        assert!(hover("close > 1", 1, 9).is_none()); // the number `1`
617        assert!(hover("", 1, 1).is_none());
618    }
619
620    #[test]
621    fn hover_ignores_lex_errors() {
622        assert!(hover("close $", 1, 1).is_none());
623    }
624
625    #[test]
626    fn hover_on_op_without_aliases_omits_alias_line() {
627        // `ema` has no aliases — the alias line must be absent.
628        let h = hover("ema(close, 5)", 1, 1).unwrap();
629        assert!(h.markdown.contains("ema(of, n)"));
630        assert!(!h.markdown.contains("Aliases"), "{}", h.markdown);
631    }
632
633    #[test]
634    fn hover_on_boolean_literal_and_logical_words() {
635        assert!(hover("rank(close, ascending=true)", 1, 23)
636            .unwrap()
637            .markdown
638            .contains("boolean"));
639        assert!(hover("a and b", 1, 3).unwrap().markdown.contains("AND"));
640        assert!(hover("a or b", 1, 3).unwrap().markdown.contains("OR"));
641    }
642
643    #[test]
644    fn every_operator_symbol_has_hover_text() {
645        for sym in [">", "<", ">=", "<=", "+", "-", "*", "/"] {
646            assert!(binop_markdown(sym).is_some(), "no hover for `{sym}`");
647        }
648        assert!(binop_markdown("??").is_none());
649    }
650
651    #[test]
652    fn every_keyword_has_hover_text() {
653        for kw in ["let", "and", "or", "true", "false"] {
654            assert!(keyword_markdown(kw).is_some(), "no hover for `{kw}`");
655        }
656        assert!(keyword_markdown("close").is_none());
657    }
658
659    // --- completions --------------------------------------------------------
660
661    fn labels(items: &[CompletionItem]) -> Vec<&str> {
662        items.iter().map(|i| i.label.as_str()).collect()
663    }
664
665    #[test]
666    fn completes_op_names_by_prefix() {
667        let items = completions("sm", 1, 3);
668        let ls = labels(&items);
669        assert!(ls.contains(&"sma"));
670        assert!(!ls.contains(&"rank"), "prefix `sm` should exclude rank");
671    }
672
673    #[test]
674    fn empty_prefix_offers_the_whole_vocabulary() {
675        let items = completions("", 1, 1);
676        let ls = labels(&items);
677        assert!(ls.contains(&"sma"));
678        assert!(ls.contains(&"close"));
679        assert!(ls.contains(&"let"));
680    }
681
682    #[test]
683    fn inside_a_call_offers_keyword_arguments_first() {
684        // Cursor inside rank(...) — `ascending`/`pct` should be offered as fields.
685        let items = completions("rank(close, )", 1, 13);
686        let field = items
687            .iter()
688            .find(|i| i.label == "ascending")
689            .expect("ascending field offered");
690        assert_eq!(field.kind, CompletionKind::Field);
691        assert_eq!(field.insert_text, "ascending=");
692    }
693
694    #[test]
695    fn completes_let_bound_names() {
696        let src = "let ma = sma(close, 20)\nclose > m";
697        let items = completions(src, 2, 10);
698        let ma = items.iter().find(|i| i.label == "ma").expect("ma offered");
699        assert_eq!(ma.kind, CompletionKind::Variable);
700    }
701
702    #[test]
703    fn enclosing_call_handles_lists_closed_calls_and_leading_paren() {
704        // Inside a list literal nested in a call → still offers the call's fields.
705        let items = completions("neutralize(close, [pe, ", 1, 23);
706        assert!(items
707            .iter()
708            .any(|i| i.label == "by" && i.kind == CompletionKind::Field));
709
710        // After a fully-closed call → no enclosing call, just the vocabulary.
711        let items = completions("sma(close, 2) and cl", 1, 21);
712        assert!(labels(&items).contains(&"close"));
713        assert!(!items.iter().any(|i| i.kind == CompletionKind::Field));
714
715        // A leading `(` with no op before it must not panic (checked_sub).
716        let items = completions("(cl", 1, 4);
717        assert!(labels(&items).contains(&"close"));
718    }
719
720    #[test]
721    fn completion_survives_a_lex_error() {
722        // A stray `$` makes lexing fail; static vocabulary is still returned.
723        let items = completions("$sm", 1, 1);
724        assert!(!items.is_empty());
725        assert!(labels(&items).contains(&"sma"));
726    }
727
728    #[test]
729    fn no_duplicate_labels_of_the_same_kind() {
730        let items = completions("", 1, 1);
731        let mut seen = std::collections::HashSet::new();
732        for it in &items {
733            assert!(
734                seen.insert((it.label.clone(), it.kind)),
735                "duplicate: {} / {:?}",
736                it.label,
737                it.kind
738            );
739        }
740    }
741
742    // --- helpers ------------------------------------------------------------
743
744    #[test]
745    fn token_end_covers_every_kind_and_let_without_name() {
746        let toks = lex("let x = \"s\" >= 1 + (a) [ , ]").unwrap();
747        for t in &toks {
748            let (_, end) = token_end(t);
749            assert!(end >= t.col);
750        }
751        // A string span includes both surrounding quotes: `"s"` is three columns.
752        let str_tok = toks
753            .iter()
754            .find(|t| matches!(t.kind, TokenKind::Str(_)))
755            .unwrap();
756        assert_eq!(token_end(str_tok), (str_tok.line, str_tok.col + 3));
757        // Eof has zero width.
758        let eof = toks.last().unwrap();
759        assert_eq!(token_end(eof), (eof.line, eof.col));
760        // `let` with no following identifier introduces no binding and never panics.
761        assert!(let_bound_names(&lex("let").unwrap()).is_empty());
762    }
763
764    #[test]
765    fn completion_kind_tags_round_trip() {
766        assert_eq!(CompletionKind::Function.as_str(), "function");
767        assert_eq!(CompletionKind::Field.as_str(), "field");
768        assert_eq!(CompletionKind::Variable.as_str(), "variable");
769        assert_eq!(CompletionKind::Series.as_str(), "series");
770        assert_eq!(CompletionKind::Keyword.as_str(), "keyword");
771    }
772}