Repository: denoland/deno (language: rust)

Issue:
lsp: It's not obvious when an autocomplete will result in an import
Version: Deno 2.1.6

In projects that use tsc, autocompletes that will result in new imports being added have the text 'Add import from':
<img width="559" alt="Image" src="https://github.com/user-attachments/assets/433972d2-b77d-4034-b68b-0cfcded5d2b2" />

Whereas in Deno projects don't make this clear:
<img width="553" alt="Image" src="https://github.com/user-attachments/assets/9f887a10-7af9-4a26-98e3-51fa79e3fb1b" />

On top of that, when you import something with tsc, the entire page jumps down one line which immediately makes it clear that you just imported something. This doesn't happen with Deno, which makes it easy to accidentally import obscure stuff like `serialize` from `node:v8` when you actually meant to autocomplete a local `serializeData` symbol or something similar.

Not a huge problem, since a good linter will catch this, but it's still annoying when you open your page in the browser, only to find out it doesn't work due to some node api being imported.


Discussion / hints:
Thanks for the report, the first part is clear, but I'm having trouble understanding this:

> On top of that, when you import something with tsc, the entire page jumps down one line which immediately makes it clear that you just imported something. This doesn't happen with Deno, which makes it easy to accidentally import obscure stuff like serialize from node:v8 when you actually meant to autocomplete a local serializeData symbol or something similar.

Could you please provide a recording of this situation?
You have to scroll down until the import statements are off screen for the difference to be visible.
This is with TSC:
https://github.com/user-attachments/assets/2d31e01b-aace-4db2-adc9-4f32b3cb4e7a

And this with Deno:
https://github.com/user-attachments/assets/1bf788ee-2d22-4403-aa68-582dad9ddb2f

I wouldn't be surprised if this is not even intentional behaviour from VSCode though. So not sure if it's worth pursuing the same behaviour. Although I must say it has been very helpful for detecting accidental imports so far.
Thanks, it's much clearer now. This will require some investigation to figure out how it works.

Fix patch:
diff --git a/cli/lsp/tsc.rs b/cli/lsp/tsc.rs
index 5139c8bc15e820..a59c056201ed85 100644
--- a/cli/lsp/tsc.rs
+++ b/cli/lsp/tsc.rs
@@ -1,5 +1,6 @@
 // Copyright 2018-2025 the Deno authors. MIT license.
 
+use std::borrow::Cow;
 use std::cell::RefCell;
 use std::cmp;
 use std::collections::BTreeMap;
@@ -3612,7 +3613,26 @@ impl CompletionEntryDetails {
       None
     };
     let mut text_edit = original_item.text_edit.clone();
+    let mut code_action_descriptions = self
+      .code_actions
+      .iter()
+      .flatten()
+      .map(|a| Cow::Borrowed(a.description.as_str()))
+      .collect::<Vec<_>>();
     if let Some(specifier_rewrite) = &data.specifier_rewrite {
+      for description in &mut code_action_descriptions {
+        let specifier_index = description
+          .char_indices()
+          .find_map(|(b, c)| (c == '\'' || c == '"').then_some(b));
+        if let Some(i) = specifier_index {
+          let mut specifier_part = description.to_mut().split_off(i);
+          specifier_part = specifier_part.replace(
+            &specifier_rewrite.old_specifier,
+            &specifier_rewrite.new_specifier,
+          );
+          description.to_mut().push_str(&specifier_part);
+        }
+      }
       if let Some(text_edit) = &mut text_edit {
         let new_text = match text_edit {
           lsp::CompletionTextEdit::Edit(text_edit) => &mut text_edit.new_text,
@@ -3639,6 +3659,16 @@ impl CompletionEntryDetails {
         }
       }
     }
+    let code_action_description =
+      Some(code_action_descriptions.join("\n\n")).filter(|s| !s.is_empty());
+    let detail = Some(
+      [code_action_description, detail]
+        .into_iter()
+        .flatten()
+        .collect::<Vec<_>>()
+        .join("\n\n"),
+    )
+    .filter(|s| !s.is_empty());
     let (command, additional_text_edits) = parse_code_actions(
       self.code_actions.as_ref(),
       data,


Test patch:
diff --git a/tests/integration/lsp_tests.rs b/tests/integration/lsp_tests.rs
index 73f12e0242b167..9f5c611323269b 100644
--- a/tests/integration/lsp_tests.rs
+++ b/tests/integration/lsp_tests.rs
@@ -5845,7 +5845,7 @@ fn lsp_jsr_auto_import_completion() {
       "label": "add",
       "labelDetails": { "description": "jsr:@denotest/add@1" },
       "kind": 3,
-      "detail": "function add(a: number, b: number): number",
+      "detail": "Add import from \"jsr:@denotest/add@1\"\n\nfunction add(a: number, b: number): number",
       "documentation": { "kind": "markdown", "value": "" },
       "sortText": "\u{ffff}16_1",
       "additionalTextEdits": [
@@ -5923,7 +5923,7 @@ fn lsp_jsr_auto_import_completion_import_map() {
       "label": "add",
       "labelDetails": { "description": "add" },
       "kind": 3,
-      "detail": "function add(a: number, b: number): number",
+      "detail": "Add import from \"add\"\n\nfunction add(a: number, b: number): number",
       "documentation": { "kind": "markdown", "value": "" },
       "sortText": "\u{ffff}16_0",
       "additionalTextEdits": [
@@ -5998,7 +5998,7 @@ fn lsp_jsr_auto_import_completion_import_map_sub_path() {
       "label": "normalize",
       "labelDetails": { "description": "@std/path/posix/normalize" },
       "kind": 3,
-      "detail": "function normalize(path: string): string",
+      "detail": "Add import from \"@std/path/posix/normalize\"\n\nfunction normalize(path: string): string",
       "documentation": { "kind": "markdown", "value": "Normalize the `path`, resolving `'..'` and `'.'` segments.\nNote that resolving these segments does not necessarily mean that all will be eliminated.\nA `'..'` at the top-level will be preserved, and an empty path is canonically `'.'`.\n\n*@param* - path to be normalized" },
       "sortText": "\u{ffff}16_0",
       "additionalTextEdits": [
@@ -8155,7 +8155,7 @@ fn lsp_completions_auto_import() {
         "description": "./🦕.ts",
       },
       "kind": 3,
-      "detail": "function add(a: number, b: number): number",
+      "detail": "Add import from \"./🦕.ts\"\n\nfunction add(a: number, b: number): number",
       "documentation": {
         "kind": "markdown",
         "value": "\n\n*@example*  \n```ts\nconst result = add(1, 2);\nconsole.log(result); // 3\n```  \n\n*@param* - a - The first number  \n\n*@param* - b - The second number"
@@ -8224,7 +8224,7 @@ fn lsp_completions_auto_import_node_builtin() {
         "description": "node:url",
       },
       "kind": 3,
-      "detail": "function pathToFileURL(path: string, options?: PathToFileUrlOptions): URL",
+      "detail": "Add import from \"node:url\"\n\nfunction pathToFileURL(path: string, options?: PathToFileUrlOptions): URL",
       "documentation": {
         "kind": "markdown",
         "value": "This function ensures that `path` is resolved absolutely, and that the URL\ncontrol characters are correctly encoded when converting into a File URL.\n\n```js\nimport { pathToFileURL } from 'node:url';\n\nnew URL('/foo#1', 'file:');           // Incorrect: file:///foo#1\npathToFileURL('/foo#1');              // Correct:   file:///foo#1 (POSIX)\n\nnew URL('/some/path%.c', 'file:');    // Incorrect: file:///some/path%.c\npathToFileURL('/some/path%.c');       // Correct:   file:///some/path%.c (POSIX)\n```\n\n*@since* - v10.12.0  \n\n*@param* - path The path to convert to a File URL.  \n\n*@return* - The file URL object.",
@@ -8305,7 +8305,7 @@ fn lsp_npm_completions_auto_import_and_quick_fix_no_import_map() {
         "description": "npm:@denotest/types-exports-subpaths@1/client",
       },
       "kind": 3,
-      "detail": "function getClient(): 5",
+      "detail": "Add import from \"npm:@denotest/types-exports-subpaths@1/client\"\n\nfunction getClient(): 5",
       "documentation": {
         "kind": "markdown",
         "value": ""
@@ -8553,7 +8553,7 @@ fn lsp_npm_auto_import_and_quick_fix_byonm() {
         "description": "cowsay",
       },
       "kind": 3,
-      "detail": "function think(options: IOptions): string",
+      "detail": "Add import from \"cowsay\"\n\nfunction think(options: IOptions): string",
       "documentation": {
         "kind": "markdown",
         "value": "\n\n*@param*  \noptions ## Face :\nEither choose a mode (set the value as true) **_or_**\nset your own defined eyes and tongue to `e` and `T`.\n- ### `e` : eyes\n- ### `T` : tongue\n\n## Cow :\nEither specify a cow name (e.g. \"fox\") **_or_**\nset the value of `r` to true which selects a random cow.\n- ### `r` : random selection\n- ### `f` : cow name - from `cows` folder\n\n## Modes :\nModes are just ready-to-use faces, here's their list:\n- #### `b` : borg\n- #### `d` : dead      \n- #### `g` : greedy\n- #### `p` : paranoia\n- #### `s` : stoned\n- #### `t` : tired\n- #### `w` : youthful\n- #### `y` : wired  \n\n*@example*  \n```\n// custom cow and face\ncowsay.think({\n    text: 'Hello world!',\n    e: '^^', // eyes\n    T: 'U ', // tongue\n    f: 'USA' // name of the cow from `cows` folder\n})\n\n// using a random cow\ncowsay.think({\n    text: 'Hello world!',\n    e: 'xx', // eyes\n    r: true, // random mode - use a random cow.\n})\n\n// using a mode\ncowsay.think({\n    text: 'Hello world!',\n    y: true, // using y mode - youthful mode\n})\n```",
@@ -8716,7 +8716,7 @@ fn lsp_npm_auto_import_with_deno_types() {
         "description": "lz-string",
       },
       "kind": 2,
-      "detail": "(method) LZString.LZStringStatic.compressToBase64(uncompressed: string): string",
+      "detail": "Add import from \"lz-string\"\n\n(method) LZString.LZStringStatic.compressToBase64(uncompressed: string): string",
       "documentation": {
         "kind": "markdown",
         "value": "Compresses input string producing an instance of a ASCII UTF-16 string,\nwhich represents the original string encoded in Base64.\nThe result can be safely transported outside the browser with a\nguarantee that none of the characters produced need to be URL-encoded.\n\n*@param* - uncompressed A string which should be compressed.",
@@ -8752,7 +8752,7 @@ fn lsp_npm_auto_import_with_deno_types() {
         "description": "react",
       },
       "kind": 3,
-      "detail": "function React.createRef<T>(): React.RefObject<T>",
+      "detail": "Add import from \"react\"\n\nfunction React.createRef<T>(): React.RefObject<T>",
       "documentation": { "kind": "markdown", "value": "" },
       "sortText": "￿16_0",
       "additionalTextEdits": [
@@ -9263,7 +9263,7 @@ fn lsp_completions_auto_import_and_quick_fix_with_import_map() {
         "description": "types-exports-subpaths/client",
       },
       "kind": 3,
-      "detail": "function getClient(): 5",
+      "detail": "Add import from \"types-exports-subpaths/client\"\n\nfunction getClient(): 5",
       "documentation": {
         "kind": "markdown",
         "value": ""
@@ -9603,7 +9603,7 @@ fn lsp_completions_auto_import_and_quick_fix_with_import_map() {
         "description": "nested/entry-b",
       },
       "kind": 3,
-      "detail": "function entryB(): \"b\"",
+      "detail": "Add import from \"nested/entry-b\"\n\nfunction entryB(): \"b\"",
       "documentation": {
         "kind": "markdown",
         "value": ""
@@ -10111,7 +10111,7 @@ fn lsp_auto_imports_remote_dts() {
         "description": "http://localhost:4545/subdir/imports_declaration/interface.d.ts",
       },
       "kind": 8,
-      "detail": "interface SomeInterface",
+      "detail": "Add import from \"http://localhost:4545/subdir/imports_declaration/interface.d.ts\"\n\ninterface SomeInterface",
       "documentation": {
         "kind": "markdown",
         "value": "",