Repository: denoland/deno
Pull request: feat(lsp): interactive inlay hints

Closes #26326. Implements ctrl+clickable inlay type hints.

Changed files:
filename: cli/lsp/language_server.rs
patch: @@ -3812,7 +3812,7 @@ impl Inner {
     let maybe_inlay_hints = maybe_inlay_hints.map(|hints| {
       hints
         .iter()
-        .map(|hint| hint.to_lsp(line_index.clone()))
+        .map(|hint| hint.to_lsp(line_index.clone(), self))
         .collect()
     });
     self.performance.measure(mark);

filename: cli/lsp/tsc.rs
patch: @@ -2182,6 +2182,50 @@ impl NavigateToItem {
   }
 }
 
+#[derive(Debug, Clone, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct InlayHintDisplayPart {
+  pub text: String,
+  pub span: Option<TextSpan>,
+  pub file: Option<String>,
+}
+
+impl InlayHintDisplayPart {
+  pub fn to_lsp(
+    &self,
+    language_server: &language_server::Inner,
+  ) -> lsp::InlayHintLabelPart {
+    let location = self.file.as_ref().map(|f| {
+      let specifier =
+        resolve_url(f).unwrap_or_else(|_| INVALID_SPECIFIER.clone());
+      let file_referrer =
+        language_server.documents.get_file_referrer(&specifier);
+      let uri = language_server
+        .url_map
+        .specifier_to_uri(&specifier, file_referrer.as_deref())
+        .unwrap_or_else(|_| INVALID_URI.clone());
+      let range = self
+        .span
+        .as_ref()
+        .and_then(|s| {
+          let asset_or_doc =
+            language_server.get_asset_or_document(&specifier).ok()?;
+          Some(s.to_range(asset_or_doc.line_index()))
+        })
+        .unwrap_or_else(|| {
+          lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 0))
+        });
+      lsp::Location { uri, range }
+    });
+    lsp::InlayHintLabelPart {
+      value: self.text.clone(),
+      tooltip: None,
+      location,
+      command: None,
+    }
+  }
+}
+
 #[derive(Debug, Clone, Deserialize)]
 pub enum InlayHintKind {
   Type,
@@ -2203,17 +2247,31 @@ impl InlayHintKind {
 #[serde(rename_all = "camelCase")]
 pub struct InlayHint {
   pub text: String,
+  pub display_parts: Option<Vec<InlayHintDisplayPart>>,
   pub position: u32,
   pub kind: InlayHintKind,
   pub whitespace_before: Option<bool>,
   pub whitespace_after: Option<bool>,
 }
 
 impl InlayHint {
-  pub fn to_lsp(&self, line_index: Arc<LineIndex>) -> lsp::InlayHint {
+  pub fn to_lsp(
+    &self,
+    line_index: Arc<LineIndex>,
+    language_server: &language_server::Inner,
+  ) -> lsp::InlayHint {
     lsp::InlayHint {
       position: line_index.position_tsc(self.position.into()),
-      label: lsp::InlayHintLabel::String(self.text.clone()),
+      label: if let Some(display_parts) = &self.display_parts {
+        lsp::InlayHintLabel::LabelParts(
+          display_parts
+            .iter()
+            .map(|p| p.to_lsp(language_server))
+            .collect(),
+        )
+      } else {
+        lsp::InlayHintLabel::String(self.text.clone())
+      },
       kind: self.kind.to_lsp(),
       padding_left: self.whitespace_before,
       padding_right: self.whitespace_after,
@@ -4892,6 +4950,8 @@ pub struct UserPreferences {
   pub allow_rename_of_import_path: Option<bool>,
   #[serde(skip_serializing_if = "Option::is_none")]
   pub auto_import_file_exclude_patterns: Option<Vec<String>>,
+  #[serde(skip_serializing_if = "Option::is_none")]
+  pub interactive_inlay_hints: Option<bool>,
 }
 
 impl UserPreferences {
@@ -4909,6 +4969,7 @@ impl UserPreferences {
       include_completions_with_snippet_text: Some(
         config.snippet_support_capable(),
       ),
+      interactive_inlay_hints: Some(true),
       provide_refactor_not_applicable_reason: Some(true),
       quote_preference: Some(fmt_config.into()),
       use_label_details_in_completion_entries: Some(true),

filename: tests/integration/lsp_tests.rs
patch: @@ -1827,15 +1827,41 @@ fn lsp_hover_disabled() {
 fn lsp_inlay_hints() {
   let context = TestContextBuilder::new().use_temp_cwd().build();
   let mut client = context.new_lsp_command().build();
-  client.initialize(|builder| {
-    builder.enable_inlay_hints();
-  });
+  client.initialize_default();
+  client.change_configuration(json!({
+    "deno": {
+      "enable": true,
+    },
+    "typescript": {
+      "inlayHints": {
+        "parameterNames": {
+          "enabled": "all",
+        },
+        "parameterTypes": {
+          "enabled": true,
+        },
+        "variableTypes": {
+          "enabled": true,
+        },
+        "propertyDeclarationTypes": {
+          "enabled": true,
+        },
+        "functionLikeReturnTypes": {
+          "enabled": true,
+        },
+        "enumMemberValues": {
+          "enabled": true,
+        },
+      },
+    },
+  }));
   client.did_open(json!({
     "textDocument": {
       "uri": "file:///a/file.ts",
       "languageId": "typescript",
       "version": 1,
-      "text": r#"function a(b: string) {
+      "text": r#"
+        function a(b: string) {
           return b;
         }
 
@@ -1854,8 +1880,19 @@ fn lsp_inlay_hints() {
         }
 
         ["a"].map((v) => v + v);
-        "#
-    }
+
+        interface Bar {
+          someField: string;
+        }
+        function getBar(): Bar {
+          return { someField: "foo" };
+        }
+        // This shouldn't have a type hint because the variable name makes it
+        // redundant.
+        const bar = getBar();
+        const someValue = getBar();
+      "#,
+    },
   }));
   let res = client.write_request(
     "textDocument/inlayHint",
@@ -1864,65 +1901,130 @@ fn lsp_inlay_hints() {
         "uri": "file:///a/file.ts",
       },
       "range": {
-        "start": { "line": 0, "character": 0 },
-        "end": { "line": 19, "character": 0, }
-      }
+        "start": { "line": 1, "character": 0 },
+        "end": { "line": 31, "character": 0, },
+      },
     }),
   );
   assert_eq!(
     res,
     json!([
       {
-        "position": { "line": 0, "character": 21 },
-        "label": ": string",
+        "position": { "line": 1, "character": 29 },
+        "label": [{ "value": ": " }, { "value": "string" }],
         "kind": 1,
-        "paddingLeft": true
+        "paddingLeft": true,
       }, {
-        "position": { "line": 4, "character": 10 },
-        "label": "b:",
+        "position": { "line": 5, "character": 10 },
+        "label": [
+          {
+            "value": "b",
+            "location": {
+              "uri": "file:///a/file.ts",
+              "range": {
+                "start": { "line": 1, "character": 19 },
+                "end": { "line": 1, "character": 20 },
+              },
+            },
+          },
+          { "value": ":" },
+        ],
         "kind": 2,
-        "paddingRight": true
+        "paddingRight": true,
       }, {
-        "position": { "line": 7, "character": 11 },
+        "position": { "line": 8, "character": 11 },
         "label": "= 0",
-        "paddingLeft": true
+        "paddingLeft": true,
       }, {
-        "position": { "line": 10, "character": 17 },
-        "label": "string:",
+        "position": { "line": 11, "character": 17 },
+        "label": [
+          {
+            "value": "string",
+            "location": {
+              "uri": "deno:/asset/lib.es5.d.ts",
+              "range": {
+                "start": { "line": 41, "character": 26 },
+                "end": { "line": 41, "character": 32 },
+              },
+            },
+          },
+          { "value": ":" },
+        ],
         "kind": 2,
-        "paddingRight": true
+        "paddingRight": true,
       }, {
-        "position": { "line": 10, "character": 24 },
-        "label": "radix:",
+        "position": { "line": 11, "character": 24 },
+        "label": [
+          {
+            "value": "radix",
+            "location": {
+              "uri": "deno:/asset/lib.es5.d.ts",
+              "range": {
+                "start": { "line": 41, "character": 42 },
+                "end": { "line": 41, "character": 47 },
+              },
+            },
+          },
+          { "value": ":" },
+        ],
         "kind": 2,
-        "paddingRight": true
+        "paddingRight": true,
       }, {
-        "position": { "line": 12, "character": 15 },
-        "label": ": number",
+        "position": { "line": 13, "character": 15 },
+        "label": [{ "value": ": " }, { "value": "number" }],
         "kind": 1,
-        "paddingLeft": true
+        "paddingLeft": true,
       }, {
-        "position": { "line": 15, "character": 11 },
-        "label": ": number",
+        "position": { "line": 16, "character": 11 },
+        "label": [{ "value": ": " }, { "value": "number" }],
         "kind": 1,
-        "paddingLeft": true
+        "paddingLeft": true,
       }, {
-        "position": { "line": 18, "character": 18 },
-        "label": "callbackfn:",
+        "position": { "line": 19, "character": 18 },
+        "label": [
+          {
+            "value": "callbackfn",
+            "location": {
+              "uri": "deno:/asset/lib.es5.d.ts",
+              "range": {
+                "start": { "line": 1462, "character": 11 },
+                "end": { "line": 1462, "character": 21 },
+              },
+            },
+          },
+          { "value": ":" },
+        ],
         "kind": 2,
-        "paddingRight": true
+        "paddingRight": true,
       }, {
-        "position": { "line": 18, "character": 20 },
-        "label": ": string",
+        "position": { "line": 19, "character": 20 },
+        "label": [{ "value": ": " }, { "value": "string" }],
         "kind": 1,
-        "paddingLeft": true
+        "paddingLeft": true,
       }, {
-        "position": { "line": 18, "character": 21 },
-        "label": ": string",
+        "position": { "line": 19, "character": 21 },
+        "label": [{ "value": ": " }, { "value": "string" }],
         "kind": 1,
-        "paddingLeft": true
-      }
-    ])
+        "paddingLeft": true,
+      }, {
+        "position": { "line": 30, "character": 23 },
+        "label": [
+          { "value": ": " },
+          {
+            "value": "Bar",
+            "location": {
+              "uri": "file:///a/file.ts",
+              "range": {
+                "start": { "line": 21, "character": 18 },
+                "end": { "line": 21, "character": 21 },
+              },
+            },
+          },
+        ],
+        "kind": 1,
+        "paddingLeft": true,
+      },
+    ]),
   );
   client.shutdown();
 }

filename: tests/util/server/src/lsp.rs
patch: @@ -308,34 +308,6 @@ impl InitializeParamsBuilder {
     self
   }
 
-  pub fn enable_inlay_hints(&mut self) -> &mut Self {
-    let options = self.initialization_options_mut();
-    options.insert(
-      "inlayHints".to_string(),
-      json!({
-        "parameterNames": {
-          "enabled": "all"
-        },
-        "parameterTypes": {
-          "enabled": true
-        },
-        "variableTypes": {
-          "enabled": true
-        },
-        "propertyDeclarationTypes": {
-          "enabled": true
-        },
-        "functionLikeReturnTypes": {
-          "enabled": true
-        },
-        "enumMemberValues": {
-          "enabled": true
-        }
-      }),
-    );
-    self
-  }
-
   pub fn disable_testing_api(&mut self) -> &mut Self {
     let obj = self
       .params