Skip to main content

bbnf_analysis/features/
hover.rs

1use bbnf::generate::prettify::hints::{
2    extract_sep_string, extract_split_delim, hint_documentation,
3};
4use ls_types::*;
5
6use crate::analysis::{symbol_at_offset, SymbolAtOffset};
7use crate::state::DocumentState;
8
9pub fn hover(state: &DocumentState, position: Position) -> Option<Hover> {
10    let offset = state.line_index.position_to_offset(position);
11
12    // Check directive keyword hovers first.
13    if let Some(hover) = hover_no_collapse(state, offset) {
14        return Some(hover);
15    }
16    if let Some(hover) = hover_recover(state, offset) {
17        return Some(hover);
18    }
19    if let Some(hover) = hover_pretty(state, offset) {
20        return Some(hover);
21    }
22
23    let symbol = symbol_at_offset(&state.info, offset)?;
24
25    match symbol {
26        SymbolAtOffset::RuleDefinition(rule) => {
27            let ref_count: usize = state
28                .info
29                .rules
30                .iter()
31                .flat_map(|r| &r.references)
32                .filter(|r| r.name == rule.name)
33                .count();
34
35            let mut content = format!(
36                "```bbnf\n{} = {}\n```\n\n{} reference{}",
37                rule.name,
38                rule.rhs_text,
39                ref_count,
40                if ref_count == 1 { "" } else { "s" }
41            );
42
43            // Add analysis info block.
44            content.push_str("\n\n---\n");
45            if let Some(first_label) = state.info.first_set_labels.get(&rule.name) {
46                content.push_str(&format!("FIRST: {}\n\n", first_label));
47            }
48            let nullable = state.info.nullable_rules.contains(&rule.name);
49            content.push_str(&format!(
50                "Nullable: {}\n\n",
51                if nullable { "yes" } else { "no" }
52            ));
53            if let Some(cycle_path) = state.info.cyclic_rule_paths.get(&rule.name) {
54                content.push_str(&format!("Cyclic: yes ({})\n", cycle_path));
55            }
56
57            // Show @pretty hints if any.
58            for p in &state.info.pretties {
59                if p.rule_name == rule.name {
60                    content.push_str(&format!("\n@pretty: `{}`\n", p.hints.join(" ")));
61                    break;
62                }
63            }
64
65            Some(Hover {
66                contents: HoverContents::Markup(MarkupContent {
67                    kind: MarkupKind::Markdown,
68                    value: content,
69                }),
70                range: Some(
71                    state
72                        .line_index
73                        .span_to_range(rule.name_span.0, rule.name_span.1),
74                ),
75            })
76        }
77        SymbolAtOffset::RuleReference { name, .. } => {
78            // Look up the definition.
79            let def = state
80                .info
81                .rule_index
82                .get(&name)
83                .map(|&i| &state.info.rules[i]);
84
85            let content = if let Some(def) = def {
86                let mut s = format!("```bbnf\n{} = {}\n```", def.name, def.rhs_text);
87                // Add FIRST set for references too.
88                if let Some(first_label) = state.info.first_set_labels.get(&def.name) {
89                    s.push_str(&format!("\n\n---\nFIRST: {}", first_label));
90                }
91                s
92            } else {
93                format!("`{}` — undefined rule", name)
94            };
95
96            Some(Hover {
97                contents: HoverContents::Markup(MarkupContent {
98                    kind: MarkupKind::Markdown,
99                    value: content,
100                }),
101                range: None,
102            })
103        }
104    }
105}
106
107/// Check if the cursor is over the @no_collapse keyword or its rule name.
108fn hover_no_collapse(state: &DocumentState, offset: usize) -> Option<Hover> {
109    for nc in &state.info.no_collapses {
110        // Check keyword span: "@no_collapse" is 13 chars.
111        let kw_end = nc.span.0 + 13;
112        if offset >= nc.span.0 && offset <= kw_end {
113            // Look up rule definition for context.
114            let rule_def = state
115                .info
116                .rule_index
117                .get(&nc.rule_name)
118                .map(|&i| &state.info.rules[i]);
119
120            let mut content = String::from(
121                "### `@no_collapse` — Span Preservation\n\n\
122                 Prevents the parser from merging consecutive spans into a single `Span` \
123                 for rule `",
124            );
125            content.push_str(&nc.rule_name);
126            content.push_str("`.\n\n");
127
128            content.push_str(
129                "**Without** `@no_collapse`: repetitions (`*`, `+`) produce a single merged `Span`, \
130                 and optionals (`?`) produce `Span` instead of `Option<Span>`.\n\n\
131                 **With** `@no_collapse`: repetitions produce `Vec<Span>` and optionals produce \
132                 `Option<Span>` — preserving individual element boundaries for formatting.\n\n",
133            );
134
135            content.push_str(
136                "Use when `@pretty` directives need to format each repeated element independently \
137                 (e.g. with `sep(\"...\")` or `split(\"...\")`).\n",
138            );
139
140            if let Some(def) = rule_def {
141                content.push_str(&format!("\n---\n```bbnf\n{} = {}\n```", def.name, def.rhs_text));
142            }
143
144            return Some(Hover {
145                contents: HoverContents::Markup(MarkupContent {
146                    kind: MarkupKind::Markdown,
147                    value: content,
148                }),
149                range: Some(state.line_index.span_to_range(nc.span.0, kw_end)),
150            });
151        }
152    }
153    None
154}
155
156/// Check if the cursor is over the @recover keyword or its directive body.
157fn hover_recover(state: &DocumentState, offset: usize) -> Option<Hover> {
158    for rec in &state.info.recovers {
159        // Hover over the entire directive span (keyword + rule name + sync expr).
160        if offset >= rec.span.0 && offset <= rec.span.1 {
161            // Check if specifically over the rule name — delegate to symbol_at_offset.
162            if offset >= rec.rule_name_span.0 && offset <= rec.rule_name_span.1 {
163                // Don't handle here — let symbol_at_offset show the rule definition.
164                continue;
165            }
166
167            let rule_def = state
168                .info
169                .rule_index
170                .get(&rec.rule_name)
171                .map(|&i| &state.info.rules[i]);
172
173            let mut content = format!(
174                "### `@recover` directive — Error Recovery\n\n\
175                 Wraps rule `{}` with error recovery. When parsing fails mid-rule, the parser:\n\n\
176                 1. Records the error with position and expected tokens\n\
177                 2. Skips forward to the **sync expression**\n\
178                 3. Produces a `Recovered` sentinel node\n\
179                 4. Continues parsing subsequent rules\n\n",
180                rec.rule_name
181            );
182
183            if !rec.sync_expr_text.is_empty() {
184                content.push_str(&format!(
185                    "**Sync expression:** `{}`\n\n\
186                     The parser advances input until this expression matches, then resumes \
187                     normal parsing from that point.\n\n",
188                    rec.sync_expr_text
189                ));
190            }
191
192            content.push_str(
193                "This enables **multi-error diagnostics** — the parser reports all errors \
194                 in a single pass instead of stopping at the first failure.\n",
195            );
196
197            if let Some(def) = rule_def {
198                content.push_str(&format!(
199                    "\n---\n```bbnf\n{} = {}\n```",
200                    def.name, def.rhs_text
201                ));
202            }
203
204            return Some(Hover {
205                contents: HoverContents::Markup(MarkupContent {
206                    kind: MarkupKind::Markdown,
207                    value: content,
208                }),
209                range: Some(state.line_index.span_to_range(rec.span.0, rec.span.1)),
210            });
211        }
212    }
213    None
214}
215
216/// Check if the cursor is over a @pretty hint keyword or rule name.
217fn hover_pretty(state: &DocumentState, offset: usize) -> Option<Hover> {
218    for pretty in &state.info.pretties {
219        // Check hint keywords.
220        for (i, hint) in pretty.hints.iter().enumerate() {
221            if let Some(&(start, end)) = pretty.hint_spans.get(i) {
222                if offset >= start && offset <= end {
223                    let content = build_hint_hover(hint, &pretty.rule_name, &pretty.hints);
224                    return Some(Hover {
225                        contents: HoverContents::Markup(MarkupContent {
226                            kind: MarkupKind::Markdown,
227                            value: content,
228                        }),
229                        range: Some(state.line_index.span_to_range(start, end)),
230                    });
231                }
232            }
233        }
234
235        // Check "@pretty" keyword itself (7 chars).
236        let kw_end = pretty.span.0 + 7;
237        if offset >= pretty.span.0 && offset < kw_end {
238            let content = build_pretty_directive_hover(state, pretty);
239            return Some(Hover {
240                contents: HoverContents::Markup(MarkupContent {
241                    kind: MarkupKind::Markdown,
242                    value: content,
243                }),
244                range: Some(state.line_index.span_to_range(pretty.span.0, kw_end)),
245            });
246        }
247
248        // Check rule name in @pretty directive.
249        let (rs, re) = pretty.rule_name_span;
250        if offset >= rs && offset <= re {
251            let content = build_pretty_directive_hover(state, pretty);
252            return Some(Hover {
253                contents: HoverContents::Markup(MarkupContent {
254                    kind: MarkupKind::Markdown,
255                    value: content,
256                }),
257                range: Some(state.line_index.span_to_range(rs, re)),
258            });
259        }
260    }
261    None
262}
263
264/// Build rich hover content for a single @pretty hint keyword.
265fn build_hint_hover(hint: &str, rule_name: &str, all_hints: &[String]) -> String {
266    if let Some(sep_str) = extract_sep_string(hint) {
267        return format!(
268            "### `sep(\"{}\")` — Custom Separator\n\n\
269             Joins elements of `{}` with the separator `\"{}\"`.\n\n\
270             When combined with `group`: renders `\"{}\"` inline when the group fits, \
271             or `\"{}\"` + newline when the group breaks (trailing whitespace is trimmed \
272             on the break branch).\n\n\
273             Without `group`: uses `\"{}\"` as a flat separator between all elements.\n\n\
274             ```bbnf\n@pretty {} {} ;\n```",
275            sep_str, rule_name, sep_str,
276            sep_str, sep_str.trim_end(),
277            sep_str,
278            rule_name, all_hints.join(" ")
279        );
280    }
281
282    if let Some(delim) = extract_split_delim(hint) {
283        return format!(
284            "### `split(\"{}\")` — Format-Time Splitting\n\n\
285             Splits opaque `Span` text from `{}` on the delimiter `\"{}\"` at format time.\n\n\
286             The split is **depth-aware**: respects `()`, `[]` nesting and `\"\"`, `''` \
287             quoting — only top-level occurrences of the delimiter trigger a split.\n\n\
288             Each resulting segment becomes a separate Doc element, which can then be \
289             joined with `sep(\"...\")` or formatted with `group`/`indent`.\n\n\
290             Uses `memchr` fast-path: skips the full scan when the delimiter isn't present.\n\n\
291             ```bbnf\n@pretty {} {} ;\n```",
292            delim, rule_name, delim,
293            rule_name, all_hints.join(" ")
294        );
295    }
296
297    if let Some(doc) = hint_documentation(hint) {
298        format!(
299            "### `{}` — `@pretty` Hint\n\n{}\n\n\
300             Applied to rule `{}`.\n\n\
301             ```bbnf\n@pretty {} {} ;\n```",
302            hint, doc, rule_name,
303            rule_name, all_hints.join(" ")
304        )
305    } else {
306        format!("`@pretty` hint: **{}**\n\nUnknown hint.", hint)
307    }
308}
309
310/// Build hover content for the @pretty directive keyword or rule name.
311fn build_pretty_directive_hover(
312    state: &DocumentState,
313    pretty: &crate::state::pretty::PrettyInfo,
314) -> String {
315    let def = state
316        .info
317        .rule_index
318        .get(&pretty.rule_name)
319        .map(|&i| &state.info.rules[i]);
320
321    let mut content = format!(
322        "### `@pretty` — Formatting Directive\n\n\
323         Controls how rule `{}` is pretty-printed by the formatter.\n\n",
324        pretty.rule_name
325    );
326
327    // Show the directive itself.
328    content.push_str(&format!(
329        "```bbnf\n@pretty {} {} ;\n```\n\n",
330        pretty.rule_name,
331        pretty.hints.join(" ")
332    ));
333
334    // Describe each hint in the combination.
335    if !pretty.hints.is_empty() {
336        content.push_str("**Applied hints:**\n\n");
337        for hint in &pretty.hints {
338            if let Some(sep_str) = extract_sep_string(hint) {
339                content.push_str(&format!(
340                    "- `sep(\"{}\")` — joins elements with `\"{}\"`\n",
341                    sep_str, sep_str
342                ));
343            } else if let Some(delim) = extract_split_delim(hint) {
344                content.push_str(&format!(
345                    "- `split(\"{}\")` — splits Span text on `\"{}\"` (depth-aware)\n",
346                    delim, delim
347                ));
348            } else if let Some(desc) = bbnf::generate::prettify::hints::hint_description(hint) {
349                content.push_str(&format!("- `{}` — {}\n", hint, lowercase_first(desc)));
350            }
351        }
352        content.push('\n');
353    }
354
355    // Show the rule definition.
356    if let Some(def) = def {
357        content.push_str(&format!(
358            "---\n```bbnf\n{} = {}\n```",
359            def.name, def.rhs_text
360        ));
361    }
362
363    content
364}
365
366/// Lowercase the first character of a string (for inline descriptions).
367fn lowercase_first(s: &str) -> String {
368    let mut chars = s.chars();
369    match chars.next() {
370        None => String::new(),
371        Some(c) => c.to_lowercase().to_string() + chars.as_str(),
372    }
373}