Skip to main content

bynk_lsp/
explain_request.rs

1//! #2098: `bynk/explain` — the in-editor, offline explanation of a diagnostic
2//! code, and the "Explain …" code action that opens it.
3//!
4//! The explanation data is compiled into the toolchain
5//! ([`bynk_syntax::diagnostics::explain`]), so the server already holds the
6//! exact text for the code it just emitted: serving it from here is
7//! version-matched by construction and needs no network and no `bynk` on
8//! `PATH`. The content is the same curated blurb, example and Book link that
9//! `bynk explain <code>` prints (`bynk/src/explain.rs`); this module only
10//! re-shapes it as Markdown for a client to render.
11//!
12//! **Transport (DECISION A).** A custom request, `bynk/explain { code } →
13//! { title, markdown, href } | null`, advertised under
14//! `ServerCapabilities.experimental.explain` like the other custom requests.
15//! The request is **not** document-scoped: the params carry only the code, so
16//! the client can also look up a code it saw in CI output. `null` for any code
17//! with no curated explanation — a known-but-uncurated code and an unknown one
18//! alike; telling those apart (and saying so gracefully) is the client's job.
19//!
20//! **Code action (DECISION B).** For every diagnostic in the requested range
21//! whose code is curated, a `quickfix` that carries a client-side command
22//! ([`EXPLAIN_COMMAND`]) rather than an edit. It is appended after every real
23//! fix and never marked preferred, so it cannot preempt one. Because the
24//! command is executed by the *client*, the action is only offered to a client
25//! that says it implements it — `experimental.explainDiagnosticCommand: true`
26//! in its `ClientCapabilities` (see [`client_supports_explain_command`]). A
27//! client that does not would otherwise show a lightbulb entry that does
28//! nothing when chosen.
29
30use bynk_syntax::diagnostics;
31use bynk_syntax::span::Span;
32use tower_lsp::lsp_types::{CodeAction, CodeActionKind, CodeActionOrCommand, Command};
33
34/// The client-side command the "Explain …" code action invokes, with the
35/// diagnostic code as its single argument. The VS Code extension registers it
36/// (`vscode-bynk/src/explainView.ts`).
37pub const EXPLAIN_COMMAND: &str = "bynk.explainDiagnostic";
38
39/// The `ClientCapabilities.experimental` key a client sets to `true` to say it
40/// implements [`EXPLAIN_COMMAND`], and so wants the "Explain …" code action.
41pub const CLIENT_CAPABILITY: &str = "explainDiagnosticCommand";
42
43/// The `bynk/explain` request payload: just the diagnostic code.
44#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
45pub struct ExplainParams {
46    pub code: String,
47}
48
49/// The `bynk/explain` response for a curated code.
50#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
51pub struct WireExplanation {
52    /// The diagnostic code, the panel's heading.
53    pub title: String,
54    /// The blurb, then the example as a fenced `bynk` block. Carries no link
55    /// and no raw HTML; the client renders the "Read more" link from `href`.
56    pub markdown: String,
57    /// The hosted-Book page for this code, the same URL as the diagnostic's
58    /// `codeDescription` and `bynk explain`'s "Learn more" line.
59    pub href: String,
60}
61
62/// The explanation for `code`, or `None` when the compiler curates none (an
63/// uncurated real code, or not a code at all).
64pub fn explanation_for(code: &str) -> Option<WireExplanation> {
65    let e = diagnostics::explain(code)?;
66    let markdown = format!(
67        "{blurb}\n\n## Example\n\n```bynk\n{example}\n```\n",
68        blurb = e.blurb,
69        example = e.example.trim_end(),
70    );
71    Some(WireExplanation {
72        title: e.code.to_string(),
73        markdown,
74        href: e.href(),
75    })
76}
77
78/// Whether the client advertised `experimental.explainDiagnosticCommand: true`.
79pub fn client_supports_explain_command(experimental: Option<&serde_json::Value>) -> bool {
80    experimental
81        .and_then(|v| v.get(CLIENT_CAPABILITY))
82        .and_then(serde_json::Value::as_bool)
83        .unwrap_or(false)
84}
85
86/// One "Explain `code`" action per distinct curated code among the diagnostics
87/// whose span intersects `requested` (the same keying rule as
88/// [`crate::code_actions::quick_fixes`]). In diagnostic order.
89pub fn explain_actions(
90    diagnostics: &[bynk_ide::Diagnostic],
91    requested: Span,
92) -> Vec<CodeActionOrCommand> {
93    let mut seen: Vec<&str> = Vec::new();
94    let mut out = Vec::new();
95    for d in diagnostics {
96        let code = d.error.category;
97        if !intersects(d.error.span, requested) || seen.contains(&code) {
98            continue;
99        }
100        if diagnostics::explain(code).is_none() {
101            continue;
102        }
103        seen.push(code);
104        let title = format!("Explain `{code}`");
105        out.push(CodeActionOrCommand::CodeAction(CodeAction {
106            title: title.clone(),
107            kind: Some(CodeActionKind::QUICKFIX),
108            command: Some(Command {
109                title,
110                command: EXPLAIN_COMMAND.to_string(),
111                arguments: Some(vec![serde_json::Value::String(code.to_string())]),
112            }),
113            is_preferred: Some(false),
114            ..Default::default()
115        }));
116    }
117    out
118}
119
120/// Closed intersection over half-open spans, as in `code_actions`: a cursor
121/// on either boundary of the diagnostic still matches.
122fn intersects(a: Span, b: Span) -> bool {
123    a.start <= b.end && b.start <= a.end
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    #[test]
131    fn a_curated_code_has_title_markdown_and_href() {
132        let e = diagnostics::explain("bynk.resolve.unknown_type").unwrap();
133        let w = explanation_for("bynk.resolve.unknown_type").expect("curated");
134        assert_eq!(w.title, "bynk.resolve.unknown_type");
135        assert!(w.markdown.starts_with(e.blurb), "{}", w.markdown);
136        assert!(w.markdown.contains("```bynk\n"), "{}", w.markdown);
137        for line in e.example.lines() {
138            assert!(w.markdown.contains(line), "example line {line:?} missing");
139        }
140        assert_eq!(w.href, "https://bynk-lang.org/book/reference/types/");
141    }
142
143    #[test]
144    fn every_curated_code_explains() {
145        for e in diagnostics::EXPLANATIONS {
146            let w = explanation_for(e.code).expect("curated");
147            assert_eq!(w.href, e.href());
148            assert!(!w.markdown.contains('<'), "no raw HTML: {}", e.code);
149        }
150    }
151
152    #[test]
153    fn an_uncurated_or_unknown_code_is_none() {
154        assert!(diagnostics::lookup("bynk.resolve.duplicate_type").is_some());
155        assert!(explanation_for("bynk.resolve.duplicate_type").is_none());
156        assert!(explanation_for("bynk.not.a_real_code").is_none());
157        assert!(explanation_for("").is_none());
158    }
159
160    #[test]
161    fn the_client_capability_gate() {
162        assert!(!client_supports_explain_command(None));
163        assert!(!client_supports_explain_command(Some(&serde_json::json!(
164            {}
165        ))));
166        assert!(!client_supports_explain_command(Some(
167            &serde_json::json!({ "explainDiagnosticCommand": "yes" })
168        )));
169        assert!(client_supports_explain_command(Some(
170            &serde_json::json!({ "explainDiagnosticCommand": true })
171        )));
172    }
173
174    #[test]
175    fn params_deserialize_from_the_wire_shape() {
176        let p: ExplainParams =
177            serde_json::from_value(serde_json::json!({ "code": "bynk.resolve.unknown_name" }))
178                .unwrap();
179        assert_eq!(p.code, "bynk.resolve.unknown_name");
180    }
181}