Repository: rust-analyzer/rust-analyzer (rust)

Issue:
Environment variables when running tests
Rust Analyzer makes it really easy to run tests as you're authoring them in the editor - just using the "run test" action above the test name.

However, it would be really useful to be able to specify some environment variables when doing this - in particular, the RUST_LOG environment variable would allow for me to see logging output direct in VSCode when running these tests, whereas right now I need to go and run them in a terminal manually.

Cheers

Pull request description:
Runnable env
This PR adds on option to specify (in the settings.json) environment variables passed to the runnable.
The simplest way for all runnables in a bunch:
```jsonc
    "rust-analyzer.runnableEnv": {
        "RUN_SLOW_TESTS": "1"
    }
```

Or it is possible to specify vars more granularly:
```jsonc
    "rust-analyzer.runnableEnv": [
        {
            // "mask": null, // null mask means that this rule will be applied for all runnables
            env: {
                 "APP_ID": "1",
                 "APP_DATA": "asdf"
            }
        },
        {
            "mask": "test_name",
            "env": {
                 "APP_ID": "2", // overwrites only APP_ID
            }
        }
    ]
```

You can use any valid RegExp as a mask. Also note that a full runnable name is something like *run bin_or_example_name*, *test some::mod::test_name* or *test-mod some::mod*, so it is possible to distinguish binaries, single tests, and test modules with this masks: `"^run"`, `"^test "` (the trailing space matters!), and `"^test-mod"` respectively.

Fixes #4450

I suppose this info should be somewhere in the docs, but unsure where is the best place.

Patch:
diff --git a/crates/ra_ide/src/completion/complete_keyword.rs b/crates/ra_ide/src/completion/complete_keyword.rs
index 34d061f5af..c3c6eda339 100644
--- a/crates/ra_ide/src/completion/complete_keyword.rs
+++ b/crates/ra_ide/src/completion/complete_keyword.rs
@@ -176,10 +176,7 @@ fn complete_return(
 mod tests {
     use expect::{expect, Expect};
 
-    use crate::completion::{
-        test_utils::{check_edit, completion_list},
-        CompletionKind,
-    };
+    use crate::completion::{test_utils::completion_list, CompletionKind};
 
     fn check(ra_fixture: &str, expect: Expect) {
         let actual = completion_list(ra_fixture, CompletionKind::Keyword);
@@ -315,11 +312,6 @@ mod tests {
                 kw while
             "#]],
         );
-        check_edit(
-            "else",
-            r#"fn quux() { if true { () } <|> }"#,
-            r#"fn quux() { if true { () } else {$0} }"#,
-        );
     }
 
     #[test]
diff --git a/docs/user/manual.adoc b/docs/user/manual.adoc
index b763958fee..7816287e40 100644
--- a/docs/user/manual.adoc
+++ b/docs/user/manual.adoc
@@ -109,18 +109,6 @@ Here are some useful self-diagnostic commands:
 * To log all LSP requests, add `"rust-analyzer.trace.server": "verbose"` to the settings and look for `Server Trace` in the panel.
 * To enable client-side logging, add `"rust-analyzer.trace.extension": true` to the settings and open the `Console` tab of VS Code developer tools.
 
-==== Special `when` clause context for keybindings.
-You may use `inRustProject` context to configure keybindings for rust projects only. For example:
-[source,json]
-----
-{
-  "key": "ctrl+i",
-  "command": "rust-analyzer.toggleInlayHints",
-  "when": "inRustProject"
-}
-----
-More about `when` clause contexts https://code.visualstudio.com/docs/getstarted/keybindings#_when-clause-contexts[here].
-
 === rust-analyzer Language Server Binary
 
 Other editors generally require the `rust-analyzer` binary to be in `$PATH`.
@@ -337,3 +325,47 @@ They are usually triggered by a shortcut or by clicking a light bulb icon in the
 Cursor position or selection is signified by `┃` character.
 
 include::./generated_assists.adoc[]
+
+== Editor Features
+=== VS Code
+==== Special `when` clause context for keybindings.
+You may use `inRustProject` context to configure keybindings for rust projects only. For example:
+[source,json]
+----
+{
+  "key": "ctrl+i",
+  "command": "rust-analyzer.toggleInlayHints",
+  "when": "inRustProject"
+}
+----
+More about `when` clause contexts https://code.visualstudio.com/docs/getstarted/keybindings#_when-clause-contexts[here].
+
+==== Setting runnable environment variables
+You can use "rust-analyzer.runnableEnv" setting to define runnable environment-specific substitution variables.
+The simplest way for all runnables in a bunch:
+```jsonc
+"rust-analyzer.runnableEnv": {
+    "RUN_SLOW_TESTS": "1"
+}
+```
+
+Or it is possible to specify vars more granularly:
+```jsonc
+"rust-analyzer.runnableEnv": [
+    {
+        // "mask": null, // null mask means that this rule will be applied for all runnables
+        env: {
+             "APP_ID": "1",
+             "APP_DATA": "asdf"
+        }
+    },
+    {
+        "mask": "test_name",
+        "env": {
+             "APP_ID": "2", // overwrites only APP_ID
+        }
+    }
+]
+```
+
+You can use any valid RegExp as a mask. Also note that a full runnable name is something like *run bin_or_example_name*, *test some::mod::test_name* or *test-mod some::mod*, so it is possible to distinguish binaries, single tests, and test modules with this masks: `"^run"`, `"^test "` (the trailing space matters!), and `"^test-mod"` respectively.
diff --git a/editors/code/package.json b/editors/code/package.json
index af0a5c851f..7c8b2fbece 100644
--- a/editors/code/package.json
+++ b/editors/code/package.json
@@ -344,6 +344,35 @@
                     "default": null,
                     "description": "Custom cargo runner extension ID."
                 },
+                "rust-analyzer.runnableEnv": {
+                    "anyOf": [
+                        {
+                            "type": "null"
+                        },
+                        {
+                            "type": "array",
+                            "items": {
+                                "type": "object",
+                                "properties": {
+                                    "mask": {
+                                        "type": "string",
+                                        "description": "Runnable name mask"
+                                    },
+                                    "env": {
+                                        "type": "object",
+                                        "description": "Variables in form of { \"key\": \"value\"}"
+                                    }
+                                }
+                            }
+                        },
+                        {
+                            "type": "object",
+                            "description": "Variables in form of { \"key\": \"value\"}"
+                        }
+                    ],
+                    "default": null,
+                    "description": "Environment variables passed to the runnable launched using `Test ` or `Debug` lens or `rust-analyzer.run` command."
+                },
                 "rust-analyzer.inlayHints.enable": {
                     "type": "boolean",
                     "default": true,
diff --git a/editors/code/src/config.ts b/editors/code/src/config.ts
index fc95a7de6b..23975c7261 100644
--- a/editors/code/src/config.ts
+++ b/editors/code/src/config.ts
@@ -5,6 +5,8 @@ export type UpdatesChannel = "stable" | "nightly";
 
 export const NIGHTLY_TAG = "nightly";
 
+export type RunnableEnvCfg = undefined | Record<string, string> | { mask?: string; env: Record<string, string> }[];
+
 export class Config {
     readonly extensionId = "matklad.rust-analyzer";
 
@@ -114,6 +116,10 @@ export class Config {
         return this.get<string | undefined>("cargoRunner");
     }
 
+    get runnableEnv() {
+        return this.get<RunnableEnvCfg>("runnableEnv");
+    }
+
     get debug() {
         // "/rustc/<id>" used by suggestions only.
         const { ["/rustc/<id>"]: _, ...sourceFileMap } = this.get<Record<string, string>>("debug.sourceFileMap");
diff --git a/editors/code/src/debug.ts b/editors/code/src/debug.ts
index 61c12dbe07..bd92c5b6d7 100644
--- a/editors/code/src/debug.ts
+++ b/editors/code/src/debug.ts
@@ -5,9 +5,10 @@ import * as ra from './lsp_ext';
 
 import { Cargo } from './toolchain';
 import { Ctx } from "./ctx";
+import { prepareEnv } from "./run";
 
 const debugOutput = vscode.window.createOutputChannel("Debug");
-type DebugConfigProvider = (config: ra.Runnable, executable: string, sourceFileMap?: Record<string, string>) => vscode.DebugConfiguration;
+type DebugConfigProvider = (config: ra.Runnable, executable: string, env: Record<string, string>, sourceFileMap?: Record<string, string>) => vscode.DebugConfiguration;
 
 export async function makeDebugConfig(ctx: Ctx, runnable: ra.Runnable): Promise<void> {
     const scope = ctx.activeRustEditor?.document.uri;
@@ -92,7 +93,8 @@ async function getDebugConfiguration(ctx: Ctx, runnable: ra.Runnable): Promise<v
     }
 
     const executable = await getDebugExecutable(runnable);
-    const debugConfig = knownEngines[debugEngine.id](runnable, simplifyPath(executable), debugOptions.sourceFileMap);
+    const env = prepareEnv(runnable, ctx.config.runnableEnv);
+    const debugConfig = knownEngines[debugEngine.id](runnable, simplifyPath(executable), env, debugOptions.sourceFileMap);
     if (debugConfig.type in debugOptions.engineSettings) {
         const settingsMap = (debugOptions.engineSettings as any)[debugConfig.type];
         for (var key in settingsMap) {
@@ -121,7 +123,7 @@ async function getDebugExecutable(runnable: ra.Runnable): Promise<string> {
     return executable;
 }
 
-function getLldbDebugConfig(runnable: ra.Runnable, executable: string, sourceFileMap?: Record<string, string>): vscode.DebugConfiguration {
+function getLldbDebugConfig(runnable: ra.Runnable, executable: string, env: Record<string, string>, sourceFileMap?: Record<string, string>): vscode.DebugConfiguration {
     return {
         type: "lldb",
         request: "launch",
@@ -130,11 +132,12 @@ function getLldbDebugConfig(runnable: ra.Runnable, executable: string, sourceFil
         args: runnable.args.executableArgs,
         cwd: runnable.args.workspaceRoot,
         sourceMap: sourceFileMap,
-        sourceLanguages: ["rust"]
+        sourceLanguages: ["rust"],
+        env
     };
 }
 
-function getCppvsDebugConfig(runnable: ra.Runnable, executable: string, sourceFileMap?: Record<string, string>): vscode.DebugConfiguration {
+function getCppvsDebugConfig(runnable: ra.Runnable, executable: string, env: Record<string, string>, sourceFileMap?: Record<string, string>): vscode.DebugConfiguration {
     return {
         type: (os.platform() === "win32") ? "cppvsdbg" : "cppdbg",
         request: "launch",
@@ -142,6 +145,7 @@ function getCppvsDebugConfig(runnable: ra.Runnable, executable: string, sourceFi
         program: executable,
         args: runnable.args.executableArgs,
         cwd: runnable.args.workspaceRoot,
-        sourceFileMap: sourceFileMap,
+        sourceFileMap,
+        env,
     };
 }
diff --git a/editors/code/src/run.ts b/editors/code/src/run.ts
index e1430e31f7..de68f27aec 100644
--- a/editors/code/src/run.ts
+++ b/editors/code/src/run.ts
@@ -5,7 +5,7 @@ import * as tasks from './tasks';
 
 import { Ctx } from './ctx';
 import { makeDebugConfig } from './debug';
-import { Config } from './config';
+import { Config, RunnableEnvCfg } from './config';
 
 const quickPickButtons = [{ iconPath: new vscode.ThemeIcon("save"), tooltip: "Save as a launch.json configurtation." }];
 
@@ -96,6 +96,30 @@ export class RunnableQuickPick implements vscode.QuickPickItem {
     }
 }
 
+export function prepareEnv(runnable: ra.Runnable, runnableEnvCfg: RunnableEnvCfg): Record<string, string> {
+    const env: Record<string, string> = { "RUST_BACKTRACE": "short" };
+
+    if (runnable.args.expectTest) {
+        env["UPDATE_EXPECT"] = "1";
+    }
+
+    Object.assign(env, process.env as { [key: string]: string });
+
+    if (runnableEnvCfg) {
+        if (Array.isArray(runnableEnvCfg)) {
+            for (const it of runnableEnvCfg) {
+                if (!it.mask || new RegExp(it.mask).test(runnable.label)) {
+                    Object.assign(env, it.env);
+                }
+            }
+        } else {
+            Object.assign(env, runnableEnvCfg);
+        }
+    }
+
+    return env;
+}
+
 export async function createTask(runnable: ra.Runnable, config: Config): Promise<vscode.Task> {
     if (runnable.kind !== "cargo") {
         // rust-analyzer supports only one kind, "cargo"
@@ -108,16 +132,13 @@ export async function createTask(runnable: ra.Runnable, config: Config): Promise
     if (runnable.args.executableArgs.length > 0) {
         args.push('--', ...runnable.args.executableArgs);
     }
-    const env: { [key: string]: string } = { "RUST_BACKTRACE": "short" };
-    if (runnable.args.expectTest) {
-        env["UPDATE_EXPECT"] = "1";
-    }
+
     const definition: tasks.CargoTaskDefinition = {
         type: tasks.TASK_TYPE,
         command: args[0], // run, test, etc...
         args: args.slice(1),
-        cwd: runnable.args.workspaceRoot,
-        env: Object.assign({}, process.env as { [key: string]: string }, env),
+        cwd: runnable.args.workspaceRoot || ".",
+        env: prepareEnv(runnable, config.runnableEnv),
     };
 
     const target = vscode.workspace.workspaceFolders![0]; // safe, see main activate()


Test patch:
diff --git a/crates/ra_ide/src/completion/test_utils.rs b/crates/ra_ide/src/completion/test_utils.rs
index 9c036eac7f..5938415b3c 100644
--- a/crates/ra_ide/src/completion/test_utils.rs
+++ b/crates/ra_ide/src/completion/test_utils.rs
@@ -1,10 +1,8 @@
 //! Runs completion for testing purposes.
 
 use hir::Semantics;
-use itertools::Itertools;
 use ra_syntax::{AstNode, NodeOrToken, SyntaxElement};
 use stdx::format_to;
-use test_utils::assert_eq_text;
 
 use crate::{
     completion::{completion_item::CompletionKind, CompletionConfig},
@@ -56,17 +54,6 @@ pub(crate) fn completion_list_with_options(
         .collect()
 }
 
-pub(crate) fn check_edit(what: &str, ra_fixture_before: &str, ra_fixture_after: &str) {
-    let (analysis, position) = analysis_and_position(ra_fixture_before);
-    let completions: Vec<CompletionItem> =
-        analysis.completions(&CompletionConfig::default(), position).unwrap().unwrap().into();
-    let (completion,) =
-        completions.into_iter().filter(|it| it.label() == what).collect_tuple().unwrap();
-    let mut actual = analysis.file_text(position.file_id).unwrap().to_string();
-    completion.text_edit().apply(&mut actual);
-    assert_eq_text!(ra_fixture_after, &actual)
-}
-
 pub(crate) fn check_pattern_is_applicable(code: &str, check: fn(SyntaxElement) -> bool) {
     let (analysis, pos) = analysis_and_position(code);
     analysis
diff --git a/editors/code/tests/unit/runnable_env.test.ts b/editors/code/tests/unit/runnable_env.test.ts
new file mode 100644
index 0000000000..f2f53e91ad
--- /dev/null
+++ b/editors/code/tests/unit/runnable_env.test.ts
@@ -0,0 +1,118 @@
+import * as assert from 'assert';
+import { prepareEnv } from '../../src/run';
+import { RunnableEnvCfg } from '../../src/config';
+import * as ra from '../../src/lsp_ext';
+
+function makeRunnable(label: string): ra.Runnable {
+    return {
+        label,
+        kind: "cargo",
+        args: {
+            cargoArgs: [],
+            executableArgs: []
+        }
+    };
+}
+
+function fakePrepareEnv(runnableName: string, config: RunnableEnvCfg): Record<string, string> {
+    const runnable = makeRunnable(runnableName);
+    return prepareEnv(runnable, config);
+}
+
+suite('Runnable env', () => {
+    test('Global config works', () => {
+        const binEnv = fakePrepareEnv("run project_name", { "GLOBAL": "g" });
+        assert.equal(binEnv["GLOBAL"], "g");
+
+        const testEnv = fakePrepareEnv("test some::mod::test_name", { "GLOBAL": "g" });
+        assert.equal(testEnv["GLOBAL"], "g");
+    });
+
+    test('null mask works', () => {
+        const config = [
+            {
+                env: { DATA: "data" }
+            }
+        ];
+        const binEnv = fakePrepareEnv("run project_name", config);
+        assert.equal(binEnv["DATA"], "data");
+
+        const testEnv = fakePrepareEnv("test some::mod::test_name", config);
+        assert.equal(testEnv["DATA"], "data");
+    });
+
+    test('order works', () => {
+        const config = [
+            {
+                env: { DATA: "data" }
+            },
+            {
+                env: { DATA: "newdata" }
+            }
+        ];
+        const binEnv = fakePrepareEnv("run project_name", config);
+        assert.equal(binEnv["DATA"], "newdata");
+
+        const testEnv = fakePrepareEnv("test some::mod::test_name", config);
+        assert.equal(testEnv["DATA"], "newdata");
+    });
+
+    test('mask works', () => {
+        const config = [
+            {
+                env: { DATA: "data" }
+            },
+            {
+                mask: "^run",
+                env: { DATA: "rundata" }
+            },
+            {
+                mask: "special_test$",
+                env: { DATA: "special_test" }
+            }
+        ];
+        const binEnv = fakePrepareEnv("run project_name", config);
+        assert.equal(binEnv["DATA"], "rundata");
+
+        const testEnv = fakePrepareEnv("test some::mod::test_name", config);
+        assert.equal(testEnv["DATA"], "data");
+
+        const specialTestEnv = fakePrepareEnv("test some::mod::special_test", config);
+        assert.equal(specialTestEnv["DATA"], "special_test");
+    });
+
+    test('exact test name works', () => {
+        const config = [
+            {
+                env: { DATA: "data" }
+            },
+            {
+                mask: "some::mod::test_name",
+                env: { DATA: "test special" }
+            }
+        ];
+        const testEnv = fakePrepareEnv("test some::mod::test_name", config);
+        assert.equal(testEnv["DATA"], "test special");
+
+        const specialTestEnv = fakePrepareEnv("test some::mod::another_test", config);
+        assert.equal(specialTestEnv["DATA"], "data");
+    });
+
+    test('test mod name works', () => {
+        const config = [
+            {
+                env: { DATA: "data" }
+            },
+            {
+                mask: "some::mod",
+                env: { DATA: "mod special" }
+            }
+        ];
+        const testEnv = fakePrepareEnv("test some::mod::test_name", config);
+        assert.equal(testEnv["DATA"], "mod special");
+
+        const specialTestEnv = fakePrepareEnv("test some::mod::another_test", config);
+        assert.equal(specialTestEnv["DATA"], "mod special");
+    });
+
+});