Skip to main content

bynk_lsp/
fill_arms.rs

1//! #2100 slice 3: the fill-match-arms quick fix. It adds an arm for every
2//! variant (or `Bool` value, or the `_` an unbounded literal match or an
3//! extensible enum needs) that a non-exhaustive `match` leaves uncovered.
4//!
5//! The checker raises one `bynk.types.non_exhaustive_match` per missing
6//! witness, and one `bynk.types.extensible_match_needs_wildcard` when the
7//! enum may gain variants (its witness is the wildcard, `_` or `Some(_)`,
8//! that covers them). Each carries a `HasPlaceholders` suggestion inserting
9//! `\n<witness> => <default>` after the last arm, where `<default>` is a
10//! typed default of the arm result type (`0`, `""`, `None`, `[]`, …). The
11//! suggestion is never a one-click quick fix or a fix-all candidate (both take
12//! only `MachineApplicable`). This module collects every suggestion on one
13//! `match` into one action and lays the arms out the way `bynk-fmt` prints
14//! them: one per line at the last arm's indentation, each ending in `,`.
15//!
16//! The action is offered only when **every** witness of the match has a
17//! suggestion. A result type with no default (a record, a user sum, a refined
18//! type), a `let … else match`, or a multi-field witness the checker can't
19//! write as a pattern means no action. Filling only some of the arms would
20//! still leave the match non-exhaustive.
21//!
22//! Fill-arms rides the unfiltered lightbulb request, so it's listed without
23//! an edit (`data` names the match) and the edit is computed, and re-checked
24//! (DECISION B), in `codeAction/resolve`.
25
26use bynk_syntax::error::Applicability;
27use bynk_syntax::span::Span;
28use tower_lsp::lsp_types::*;
29
30/// The categories a missing arm is reported under: a missing variant (or
31/// value), and the wildcard an extensible enum's future variants need.
32const CATEGORIES: [&str; 2] = [
33    "bynk.types.non_exhaustive_match",
34    "bynk.types.extensible_match_needs_wildcard",
35];
36const MESSAGE_PREFIX: &str = "add a match arm for ";
37const DATA_TAG: &str = "bynk.fillMatchArms";
38
39/// The `match` spans (the non-exhaustive diagnostics' span) that intersect
40/// `requested` and have an arm suggestion for every missing witness, in
41/// source order.
42pub fn fillable(diagnostics: &[bynk_ide::Diagnostic], requested: Span) -> Vec<Span> {
43    let mut spans: Vec<Span> = diagnostics
44        .iter()
45        .filter(|d| CATEGORIES.contains(&d.error.category))
46        .map(|d| d.error.span)
47        .filter(|s| s.start <= requested.end && requested.start <= s.end)
48        .collect();
49    spans.sort_by_key(|s| (s.start, s.end));
50    spans.dedup_by(|a, b| same_span(*a, *b));
51    spans.retain(|s| arms(diagnostics, *s).is_some());
52    spans
53}
54
55/// Spans compare by offsets: a span rebuilt from an action's `data` has no
56/// file id.
57fn same_span(a: Span, b: Span) -> bool {
58    (a.start, a.end) == (b.start, b.end)
59}
60
61/// The `(anchor, arm text)` for every missing witness of the match at
62/// `match_span`, or `None` unless every one of them has a suggestion and they
63/// share one anchor.
64fn arms(diagnostics: &[bynk_ide::Diagnostic], match_span: Span) -> Option<(usize, Vec<String>)> {
65    let mut anchor = None;
66    let mut out = Vec::new();
67    for d in diagnostics
68        .iter()
69        .filter(|d| CATEGORIES.contains(&d.error.category) && same_span(d.error.span, match_span))
70    {
71        let s = d.error.suggestions.iter().find(|s| {
72            s.applicability == Applicability::HasPlaceholders
73                && s.message.starts_with(MESSAGE_PREFIX)
74        })?;
75        let [(span, text)] = s.edits.as_slice() else {
76            return None;
77        };
78        if *anchor.get_or_insert(span.start) != span.start || span.start != span.end {
79            return None;
80        }
81        let arm = text.trim_start_matches('\n').to_string();
82        if !out.contains(&arm) {
83            out.push(arm);
84        }
85    }
86    Some((anchor?, out))
87}
88
89/// The edits that fill the match at `match_span` in `text`, laid out as
90/// `bynk-fmt` prints arms. `None` when there is nothing to fill or the last
91/// arm's line doesn't allow it: the match closes on that line, or the arm
92/// shares it with other code. A `bynk-fmt`-canonical file stays canonical.
93pub fn edits(
94    text: &str,
95    diagnostics: &[bynk_ide::Diagnostic],
96    match_span: Span,
97) -> Option<Vec<(Span, String)>> {
98    let (anchor, arms) = arms(diagnostics, match_span)?;
99    let line_start = text[..anchor].rfind('\n').map_or(0, |i| i + 1);
100    let line_end = text[anchor..].find('\n').map_or(text.len(), |i| anchor + i);
101    let indent: String = text[line_start..]
102        .chars()
103        .take_while(|c| *c == ' ' || *c == '\t')
104        .collect();
105    let rest = text[anchor..line_end].trim_start();
106    let (has_comma, after) = match rest.strip_prefix(',') {
107        Some(after) => (true, after.trim_start()),
108        None => (false, rest),
109    };
110    if !after.is_empty() && !after.starts_with("//") {
111        return None;
112    }
113    let mut out = Vec::new();
114    if !has_comma {
115        out.push((Span::new(anchor, anchor), ",".to_string()));
116    }
117    let inserted: String = arms.iter().map(|a| format!("\n{indent}{a},")).collect();
118    out.push((Span::new(line_end, line_end), inserted));
119
120    let filled = crate::fix_all::apply(text, &out);
121    let opts = bynk_fmt::FormatOptions::default();
122    let canonical = |s: &str| bynk_fmt::format_source(s, &opts).is_ok_and(|f| f == s);
123    if canonical(text) && !canonical(&filled) {
124        return None;
125    }
126    Some(out)
127}
128
129/// The listed (unresolved) action for the match at `match_span`: a quick fix
130/// on its diagnostics, with no edit yet. `data` carries what
131/// `codeAction/resolve` needs.
132pub fn listed(
133    diagnostics: &[bynk_ide::Diagnostic],
134    match_span: Span,
135    uri: &Url,
136    version: Option<i32>,
137) -> CodeActionOrCommand {
138    // The arms actually inserted, so the title matches the edit.
139    let count = arms(diagnostics, match_span).map_or(0, |(_, arms)| arms.len());
140    CodeActionOrCommand::CodeAction(CodeAction {
141        title: if count == 1 {
142            "Fill the missing match arm".to_string()
143        } else {
144            format!("Fill {count} missing match arms")
145        },
146        kind: Some(CodeActionKind::QUICKFIX),
147        data: Some(serde_json::json!({
148            "kind": DATA_TAG,
149            "uri": uri.as_str(),
150            "version": version,
151            "start": match_span.start,
152            "end": match_span.end,
153        })),
154        ..Default::default()
155    })
156}
157
158/// What a listed fill-arms action's `data` names, or `None` when the action
159/// is not one of ours.
160pub struct Target {
161    pub uri: Url,
162    pub version: Option<i32>,
163    pub match_span: Span,
164}
165
166impl Target {
167    pub fn of(action: &CodeAction) -> Option<Self> {
168        let data = action.data.as_ref()?;
169        if data.get("kind")?.as_str()? != DATA_TAG {
170            return None;
171        }
172        Some(Target {
173            uri: Url::parse(data.get("uri")?.as_str()?).ok()?,
174            version: data
175                .get("version")
176                .and_then(serde_json::Value::as_i64)
177                .and_then(|v| i32::try_from(v).ok()),
178            match_span: Span::new(
179                usize::try_from(data.get("start")?.as_u64()?).ok()?,
180                usize::try_from(data.get("end")?.as_u64()?).ok()?,
181            ),
182        })
183    }
184}
185
186/// `action` with its versioned edit filled in.
187pub fn resolve(
188    mut action: CodeAction,
189    text: &str,
190    edits: &[(Span, String)],
191    uri: &Url,
192    version: Option<i32>,
193) -> CodeAction {
194    action.edit = Some(WorkspaceEdit {
195        changes: None,
196        document_changes: Some(DocumentChanges::Edits(vec![TextDocumentEdit {
197            text_document: OptionalVersionedTextDocumentIdentifier {
198                uri: uri.clone(),
199                version,
200            },
201            edits: edits
202                .iter()
203                .map(|(span, new_text)| {
204                    OneOf::Left(TextEdit {
205                        range: crate::position::span_to_range(text, *span),
206                        new_text: new_text.clone(),
207                    })
208                })
209                .collect(),
210        }])),
211        change_annotations: None,
212    });
213    action
214}