Skip to main content

doge_compiler/
complete.rs

1//! Completion for the language server: given a cursor position in a `.doge`
2//! buffer, the candidate names to offer. Every candidate is read from the same
3//! single-source tables the checker and codegen use — [`KEYWORDS`], [`BUILTINS`],
4//! and the stdlib [`MODULES`] — plus in-scope names computed from the parsed AST,
5//! so completion never drifts from what the compiler actually accepts.
6//!
7//! Completion runs on whatever the editor has in the buffer, which is often
8//! mid-edit and does not parse. The AST path is used when the buffer parses; when
9//! it does not, a best-effort token scan still surfaces declared names so
10//! completion keeps working while the user types. This token scan is a resilience
11//! fallback, not a second definition of the language — the binding rules live in
12//! the AST walker ([`hoisted_names`]/[`child_funcdefs`]).
13
14use std::collections::HashSet;
15
16use crate::ast::{hoisted_names, Params, Stmt};
17use crate::builtins::BUILTINS;
18use crate::keywords::KEYWORDS;
19use crate::stdlib::{self, MODULES};
20use crate::token::{Span, TokenKind};
21
22/// One completion candidate: the text to insert and what kind of thing it is
23/// (so the editor can show the right icon).
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct Completion {
26    pub label: String,
27    pub kind: CompletionKind,
28}
29
30/// The category of a [`Completion`], mapped to an editor icon by the language
31/// server.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum CompletionKind {
34    Keyword,
35    Builtin,
36    Variable,
37    Function,
38    Module,
39    Member,
40}
41
42/// The completion candidates offered at (`line`, `col`) — both 1-based, matching
43/// the compiler's [`Span`] convention — in `source` (named `path` for lexing).
44pub fn complete(path: &str, source: &str, line: u32, col: u32) -> Vec<Completion> {
45    match cursor_context(source, line, col) {
46        Context::Member(base) => member_completions(path, source, &base),
47        Context::Import => module_name_completions(),
48        Context::General => general_completions(path, source, line, col),
49    }
50}
51
52/// What the text just before the cursor tells us to complete.
53enum Context {
54    /// `base.<partial>` — the members of `base`.
55    Member(String),
56    /// Just after `so ` — a module name.
57    Import,
58    /// Anything else — keywords, builtins, and in-scope names.
59    General,
60}
61
62fn is_ident_char(c: char) -> bool {
63    c.is_alphanumeric() || c == '_'
64}
65
66/// Read the current line up to the cursor and classify what is being typed.
67fn cursor_context(source: &str, line: u32, col: u32) -> Context {
68    let line_text = source
69        .split('\n')
70        .nth((line as usize).saturating_sub(1))
71        .unwrap_or("");
72    let chars: Vec<char> = line_text.chars().collect();
73    let caret = (col.saturating_sub(1) as usize).min(chars.len());
74    let prefix = &chars[..caret];
75
76    // Skip back over the partial word currently under the cursor.
77    let mut word = caret;
78    while word > 0 && is_ident_char(prefix[word - 1]) {
79        word -= 1;
80    }
81
82    // `base.<partial>`: a dot directly before the partial word, an identifier
83    // directly before the dot (no spaces — member access is tight).
84    if word > 0 && prefix[word - 1] == '.' {
85        let dot = word - 1;
86        let mut start = dot;
87        while start > 0 && is_ident_char(prefix[start - 1]) {
88            start -= 1;
89        }
90        let base: String = prefix[start..dot].iter().collect();
91        if !base.is_empty() {
92            return Context::Member(base);
93        }
94    }
95
96    // `so <partial>`: the word before the cursor (across spaces) is `so`.
97    let mut before = word;
98    while before > 0 && prefix[before - 1].is_whitespace() {
99        before -= 1;
100    }
101    let mut prev_start = before;
102    while prev_start > 0 && is_ident_char(prefix[prev_start - 1]) {
103        prev_start -= 1;
104    }
105    if prefix[prev_start..before].iter().collect::<String>() == "so" {
106        return Context::Import;
107    }
108
109    Context::General
110}
111
112/// The members of `base`, but only when `base` names a stdlib module the buffer
113/// has imported. An unknown base (e.g. an object variable) yields nothing rather
114/// than misleading suggestions — object-member completion is future work.
115fn member_completions(path: &str, source: &str, base: &str) -> Vec<Completion> {
116    if !imported_modules(path, source).contains(base) {
117        return Vec::new();
118    }
119    let Some(module) = stdlib::module(base) else {
120        return Vec::new();
121    };
122    let mut out = Vec::new();
123    for func in module.funcs {
124        out.push(Completion {
125            label: func.name.to_string(),
126            kind: CompletionKind::Member,
127        });
128    }
129    for (name, _) in module.consts {
130        out.push(Completion {
131            label: name.to_string(),
132            kind: CompletionKind::Member,
133        });
134    }
135    out
136}
137
138/// Every importable module name.
139fn module_name_completions() -> Vec<Completion> {
140    MODULES
141        .iter()
142        .map(|m| Completion {
143            label: m.name.to_string(),
144            kind: CompletionKind::Module,
145        })
146        .collect()
147}
148
149/// Keywords, builtins, imported modules, and the names in scope at the cursor.
150fn general_completions(path: &str, source: &str, line: u32, col: u32) -> Vec<Completion> {
151    let mut out = Vec::new();
152    let mut seen = HashSet::new();
153    let mut push = |out: &mut Vec<Completion>, label: String, kind: CompletionKind| {
154        if seen.insert(label.clone()) {
155            out.push(Completion { label, kind });
156        }
157    };
158
159    for (spelling, kind) in KEYWORDS {
160        // `def`/`class` are reserved only to greet Python muscle memory with a
161        // hint; they are not Doge keywords, so never offer them.
162        if matches!(kind, TokenKind::Def | TokenKind::Class) {
163            continue;
164        }
165        push(&mut out, spelling.to_string(), CompletionKind::Keyword);
166    }
167    for builtin in BUILTINS {
168        push(&mut out, builtin.name.to_string(), CompletionKind::Builtin);
169    }
170    for module in imported_modules(path, source) {
171        push(&mut out, module, CompletionKind::Module);
172    }
173    for (name, kind) in in_scope_names(path, source, line, col) {
174        push(&mut out, name, kind);
175    }
176    out
177}
178
179/// The names visible at (`line`, `col`). Uses the parsed AST when the buffer
180/// parses; otherwise falls back to a token scan of declared names.
181fn in_scope_names(path: &str, source: &str, line: u32, col: u32) -> Vec<(String, CompletionKind)> {
182    match crate::parser::parse(path, source) {
183        Ok(script) => {
184            let mut callables = HashSet::new();
185            collect_callables(&script.stmts, &mut callables);
186            let mut names = Vec::new();
187            scope_names_at(&script.stmts, line, col, &mut names);
188            names
189                .into_iter()
190                .map(|name| {
191                    let kind = if callables.contains(&name) {
192                        CompletionKind::Function
193                    } else {
194                        CompletionKind::Variable
195                    };
196                    (name, kind)
197                })
198                .collect()
199        }
200        Err(_) => lexical_names(path, source)
201            .into_iter()
202            .map(|name| (name, CompletionKind::Variable))
203            .collect(),
204    }
205}
206
207/// True when the cursor at (`line`, `col`) is at or after `start`.
208fn at_or_after(start: Span, line: u32, col: u32) -> bool {
209    line > start.line || (line == start.line && col >= start.col)
210}
211
212/// True when the cursor at (`line`, `col`) falls in `[start, end)` — `end` is the
213/// next sibling's start, or `None` at the end of the block (open upper bound).
214fn within(start: Span, end: Option<Span>, line: u32, col: u32) -> bool {
215    if !at_or_after(start, line, col) {
216        return false;
217    }
218    match end {
219        None => true,
220        Some(end) => line < end.line || (line == end.line && col < end.col),
221    }
222}
223
224/// Collect the names visible at the cursor: this block's hoisted names, plus the
225/// scope-introduced names (params, loop vars, error name) of the innermost
226/// construct the cursor sits inside. Over-inclusion is preferred to omission —
227/// suggesting a slightly out-of-scope name is a smaller harm than hiding a valid
228/// one, and the checker still flags a genuine misuse.
229fn scope_names_at(stmts: &[Stmt], line: u32, col: u32, out: &mut Vec<String>) {
230    for name in hoisted_names(stmts) {
231        push_unique(out, name);
232    }
233    for (i, stmt) in stmts.iter().enumerate() {
234        let end = stmts.get(i + 1).map(|next| next.span());
235        if !within(stmt.span(), end, line, col) {
236            continue;
237        }
238        match stmt {
239            Stmt::FuncDef { params, body, .. } => {
240                push_params(params, out);
241                scope_names_at(body, line, col, out);
242            }
243            Stmt::ObjDef { methods, .. } => scope_names_at(methods, line, col, out),
244            Stmt::For {
245                vars, rest, body, ..
246            } => {
247                for var in vars {
248                    push_unique(out, var.clone());
249                }
250                if let Some(rest) = rest {
251                    push_unique(out, rest.clone());
252                }
253                scope_names_at(body, line, col, out);
254            }
255            Stmt::While { body, .. } => scope_names_at(body, line, col, out),
256            Stmt::If {
257                branches,
258                else_body,
259                ..
260            } => {
261                for (_, body) in branches {
262                    scope_names_at(body, line, col, out);
263                }
264                if let Some(body) = else_body {
265                    scope_names_at(body, line, col, out);
266                }
267            }
268            Stmt::Try {
269                body,
270                err_name,
271                handler,
272                ..
273            } => {
274                scope_names_at(body, line, col, out);
275                push_unique(out, err_name.clone());
276                scope_names_at(handler, line, col, out);
277            }
278            _ => {}
279        }
280        break;
281    }
282}
283
284fn push_params(params: &Params, out: &mut Vec<String>) {
285    for name in params.binding_names() {
286        push_unique(out, name);
287    }
288}
289
290fn push_unique(out: &mut Vec<String>, name: String) {
291    if !out.contains(&name) {
292        out.push(name);
293    }
294}
295
296/// Every function and object (class) name declared anywhere in the program,
297/// descending through nested function and object bodies too — used only to tag a
298/// completion as a function rather than a plain variable.
299fn collect_callables(stmts: &[Stmt], out: &mut HashSet<String>) {
300    for stmt in stmts {
301        match stmt {
302            Stmt::FuncDef { name, body, .. } => {
303                out.insert(name.clone());
304                collect_callables(body, out);
305            }
306            Stmt::ObjDef { name, methods, .. } => {
307                out.insert(name.clone());
308                collect_callables(methods, out);
309            }
310            other => {
311                crate::ast::for_each_child_block(other, &mut |block| collect_callables(block, out))
312            }
313        }
314    }
315}
316
317/// The module names the buffer imports (`so nerd`), from the AST when it parses,
318/// else from a token scan.
319fn imported_modules(path: &str, source: &str) -> HashSet<String> {
320    let mut out = HashSet::new();
321    match crate::parser::parse(path, source) {
322        Ok(script) => collect_imports(&script.stmts, &mut out),
323        Err(_) => {
324            for name in lexical_imports(path, source) {
325                out.insert(name);
326            }
327        }
328    }
329    out
330}
331
332fn collect_imports(stmts: &[Stmt], out: &mut HashSet<String>) {
333    for stmt in stmts {
334        if let Stmt::Import { module, .. } = stmt {
335            out.insert(module.clone());
336        }
337        crate::ast::for_each_child_block(stmt, &mut |block| collect_imports(block, out));
338    }
339}
340
341/// Best-effort declared names from the token stream, for when the buffer does not
342/// parse. Recognises the leading token of each binding form (`such`/`so name`,
343/// `many Name`, `much` params, `for` vars, `oh no err!`). Flat — no scoping — but
344/// enough to keep completion useful mid-edit.
345fn lexical_names(path: &str, source: &str) -> Vec<String> {
346    let tokens = crate::lexer::lex(path, source).unwrap_or_default();
347    let mut out = Vec::new();
348    for (i, token) in tokens.iter().enumerate() {
349        match &token.kind {
350            TokenKind::Such | TokenKind::So | TokenKind::Many => {
351                if let Some(TokenKind::Ident(name)) = tokens.get(i + 1).map(|t| &t.kind) {
352                    push_unique(&mut out, name.clone());
353                }
354            }
355            TokenKind::Much | TokenKind::For => {
356                for next in &tokens[i + 1..] {
357                    match &next.kind {
358                        TokenKind::Ident(name) => push_unique(&mut out, name.clone()),
359                        TokenKind::Colon | TokenKind::Newline | TokenKind::In => break,
360                        _ => {}
361                    }
362                }
363            }
364            TokenKind::OhNo => {
365                if let Some(TokenKind::Ident(name)) = tokens.get(i + 1).map(|t| &t.kind) {
366                    push_unique(&mut out, name.clone());
367                }
368            }
369            _ => {}
370        }
371    }
372    out
373}
374
375fn lexical_imports(path: &str, source: &str) -> Vec<String> {
376    let tokens = crate::lexer::lex(path, source).unwrap_or_default();
377    let mut out = Vec::new();
378    for (i, token) in tokens.iter().enumerate() {
379        if matches!(token.kind, TokenKind::So) {
380            if let Some(TokenKind::Ident(name)) = tokens.get(i + 1).map(|t| &t.kind) {
381                push_unique(&mut out, name.clone());
382            }
383        }
384    }
385    out
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391
392    fn labels(source: &str, line: u32, col: u32) -> Vec<String> {
393        complete("buf.doge", source, line, col)
394            .into_iter()
395            .map(|c| c.label)
396            .collect()
397    }
398
399    fn completion(source: &str, line: u32, col: u32, label: &str) -> Option<Completion> {
400        complete("buf.doge", source, line, col)
401            .into_iter()
402            .find(|c| c.label == label)
403    }
404
405    #[test]
406    fn general_offers_keywords_and_builtins() {
407        let got = labels("bark \n", 1, 6);
408        assert!(got.contains(&"such".to_string()));
409        assert!(got.contains(&"if".to_string()));
410        assert!(got.contains(&"len".to_string()));
411        assert!(got.contains(&"range".to_string()));
412    }
413
414    #[test]
415    fn reserved_words_are_never_offered() {
416        let got = labels("\n", 1, 1);
417        assert!(!got.contains(&"def".to_string()));
418        assert!(!got.contains(&"class".to_string()));
419    }
420
421    #[test]
422    fn top_level_names_are_in_scope() {
423        // `greet`'s own `wow` closes the function; the trailing `wow` ends the
424        // script. The cursor sits on the blank line between them.
425        let src = "such greeting = \"hi\"\nsuch greet much name:\n  bark name\nwow\n\nwow\n";
426        let got = labels(src, 5, 1);
427        assert!(got.contains(&"greeting".to_string()));
428        assert!(got.contains(&"greet".to_string()));
429        // A function name is tagged as a function, a plain binding as a variable.
430        assert_eq!(
431            completion(src, 5, 1, "greet").map(|c| c.kind),
432            Some(CompletionKind::Function)
433        );
434        assert_eq!(
435            completion(src, 5, 1, "greeting").map(|c| c.kind),
436            Some(CompletionKind::Variable)
437        );
438    }
439
440    #[test]
441    fn a_param_is_visible_only_inside_its_function() {
442        // Cursor inside the body: the parameter is offered.
443        let src = "such greet much name:\n  bark name\nwow\nwow\n";
444        assert!(labels(src, 2, 3).contains(&"name".to_string()));
445        // Cursor at a top-level statement after the function: it is not.
446        let src_after = "such greet much name:\n  bark name\nwow\nsuch other = 1\nwow\n";
447        let after = labels(src_after, 4, 1);
448        assert!(!after.contains(&"name".to_string()));
449        assert!(after.contains(&"other".to_string()));
450    }
451
452    #[test]
453    fn member_access_offers_module_members() {
454        let src = "so nerd\nbark nerd.\n";
455        let got = labels(src, 2, 11);
456        assert!(got.contains(&"sqrt".to_string()));
457        assert!(got.contains(&"floor".to_string()));
458        // Not the top-level grab-bag: keywords do not appear after a dot.
459        assert!(!got.contains(&"such".to_string()));
460    }
461
462    #[test]
463    fn member_access_on_unimported_module_is_empty() {
464        // `nerd` is a real module but was never imported here.
465        assert!(labels("bark nerd.\n", 1, 11).is_empty());
466    }
467
468    #[test]
469    fn import_position_offers_module_names() {
470        let got = labels("so \n", 1, 4);
471        assert!(got.contains(&"nerd".to_string()));
472        assert!(got.contains(&"strings".to_string()));
473        assert!(!got.contains(&"len".to_string()));
474    }
475
476    #[test]
477    fn unparsable_buffer_falls_back_to_declared_names() {
478        // A dangling `if` header does not parse, but declared names still surface.
479        let src = "such total = 0\nif total >\n";
480        let got = labels(src, 2, 11);
481        assert!(got.contains(&"total".to_string()));
482    }
483}