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