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

Issue:
Do not propose unstable features on autocomplete for stable toolchain
In the project with stable toolchain used, `Some(22).ex<|>` proposes `expect_none` [method](https://doc.rust-lang.org/std/option/enum.Option.html#method.expect_none) which is unstable currently.

![image](https://user-images.githubusercontent.com/2690773/73848680-3ea7c080-4831-11ea-9e80-7c5ac3a02a56.png)

I would expect RA not to propose unstable APIs when a stable toolchain is used.

Pull request:
Don't suggest unstable items on stable toolchain
Closes #3020

This PR implements stability check in `ide-completion` so that unstable items are only suggested if you're on nightly toolchain.

It's a bit unfortunate `CompletionContext::check_stability()` is spammed all over the crate, but we should call it before building `CompletionItem` as you cannot get attributes on the item it's completing from that struct. I looked up every callsite of `Builder::add_to()`, `Completions::add[_opt]()`, and`Completions::add_all()` and inserted the check wherever necessary.

The tests are admittedly incomplete in that I didn't add tests for every kind of item as I thought that would be too big and not worthwhile. I copy-pasted some existing basic tests in every test module and adjusted them.

Patch:
diff --git a/crates/base-db/src/fixture.rs b/crates/base-db/src/fixture.rs
index 3339580bfe..f01f75b138 100644
--- a/crates/base-db/src/fixture.rs
+++ b/crates/base-db/src/fixture.rs
@@ -4,7 +4,8 @@ use std::{mem, str::FromStr, sync::Arc};
 use cfg::CfgOptions;
 use rustc_hash::FxHashMap;
 use test_utils::{
-    extract_range_or_offset, Fixture, RangeOrOffset, CURSOR_MARKER, ESCAPED_CURSOR_MARKER,
+    extract_range_or_offset, Fixture, FixtureWithProjectMeta, RangeOrOffset, CURSOR_MARKER,
+    ESCAPED_CURSOR_MARKER,
 };
 use tt::token_id::{Leaf, Subtree, TokenTree};
 use vfs::{file_set::FileSet, VfsPath};
@@ -12,7 +13,7 @@ use vfs::{file_set::FileSet, VfsPath};
 use crate::{
     input::{CrateName, CrateOrigin, LangCrateOrigin},
     Change, CrateDisplayName, CrateGraph, CrateId, Dependency, Edition, Env, FileId, FilePosition,
-    FileRange, ProcMacro, ProcMacroExpander, ProcMacroExpansionError, ProcMacros,
+    FileRange, ProcMacro, ProcMacroExpander, ProcMacroExpansionError, ProcMacros, ReleaseChannel,
     SourceDatabaseExt, SourceRoot, SourceRootId,
 };
 
@@ -102,7 +103,14 @@ impl ChangeFixture {
         ra_fixture: &str,
         mut proc_macro_defs: Vec<(String, ProcMacro)>,
     ) -> ChangeFixture {
-        let (mini_core, proc_macro_names, fixture) = Fixture::parse(ra_fixture);
+        let FixtureWithProjectMeta { fixture, mini_core, proc_macro_names, toolchain } =
+            FixtureWithProjectMeta::parse(ra_fixture);
+        let toolchain = toolchain
+            .map(|it| {
+                ReleaseChannel::from_str(&it)
+                    .unwrap_or_else(|| panic!("unknown release channel found: {it}"))
+            })
+            .unwrap_or(ReleaseChannel::Stable);
         let mut change = Change::new();
 
         let mut files = Vec::new();
@@ -166,7 +174,7 @@ impl ChangeFixture {
                         .as_deref()
                         .map(Arc::from)
                         .ok_or_else(|| "target_data_layout unset".into()),
-                    None,
+                    Some(toolchain),
                 );
                 let prev = crates.insert(crate_name.clone(), crate_id);
                 assert!(prev.is_none());
@@ -205,7 +213,7 @@ impl ChangeFixture {
                 default_target_data_layout
                     .map(|x| x.into())
                     .ok_or_else(|| "target_data_layout unset".into()),
-                None,
+                Some(toolchain),
             );
         } else {
             for (from, to, prelude) in crate_deps {
@@ -247,7 +255,7 @@ impl ChangeFixture {
                 false,
                 CrateOrigin::Lang(LangCrateOrigin::Core),
                 target_layout.clone(),
-                None,
+                Some(toolchain),
             );
 
             for krate in all_crates {
@@ -286,7 +294,7 @@ impl ChangeFixture {
                 true,
                 CrateOrigin::Local { repo: None, name: None },
                 target_layout,
-                None,
+                Some(toolchain),
             );
             proc_macros.insert(proc_macros_crate, Ok(proc_macro));
 
diff --git a/crates/hir-def/src/attr.rs b/crates/hir-def/src/attr.rs
index 9224773aa8..cc59e78093 100644
--- a/crates/hir-def/src/attr.rs
+++ b/crates/hir-def/src/attr.rs
@@ -269,6 +269,10 @@ impl Attrs {
     pub fn is_proc_macro_derive(&self) -> bool {
         self.by_key("proc_macro_derive").exists()
     }
+
+    pub fn is_unstable(&self) -> bool {
+        self.by_key("unstable").exists()
+    }
 }
 
 use std::slice::Iter as SliceIter;
diff --git a/crates/ide-completion/src/completions.rs b/crates/ide-completion/src/completions.rs
index b6a066f4f5..6f23bc5c74 100644
--- a/crates/ide-completion/src/completions.rs
+++ b/crates/ide-completion/src/completions.rs
@@ -23,7 +23,7 @@ pub(crate) mod env_vars;
 
 use std::iter;
 
-use hir::{known, ScopeDef, Variant};
+use hir::{known, HasAttrs, ScopeDef, Variant};
 use ide_db::{imports::import_assets::LocatedImport, SymbolKind};
 use syntax::ast;
 
@@ -181,6 +181,9 @@ impl Completions {
         resolution: hir::ScopeDef,
         doc_aliases: Vec<syntax::SmolStr>,
     ) {
+        if !ctx.check_stability(resolution.attrs(ctx.db).as_deref()) {
+            return;
+        }
         let is_private_editable = match ctx.def_is_visible(&resolution) {
             Visible::Yes => false,
             Visible::Editable => true,
@@ -206,6 +209,9 @@ impl Completions {
         local_name: hir::Name,
         resolution: hir::ScopeDef,
     ) {
+        if !ctx.check_stability(resolution.attrs(ctx.db).as_deref()) {
+            return;
+        }
         let is_private_editable = match ctx.def_is_visible(&resolution) {
             Visible::Yes => false,
             Visible::Editable => true,
@@ -228,6 +234,9 @@ impl Completions {
         path_ctx: &PathCompletionCtx,
         e: hir::Enum,
     ) {
+        if !ctx.check_stability(Some(&e.attrs(ctx.db))) {
+            return;
+        }
         e.variants(ctx.db)
             .into_iter()
             .for_each(|variant| self.add_enum_variant(ctx, path_ctx, variant, None));
@@ -241,6 +250,9 @@ impl Completions {
         local_name: hir::Name,
         doc_aliases: Vec<syntax::SmolStr>,
     ) {
+        if !ctx.check_stability(Some(&module.attrs(ctx.db))) {
+            return;
+        }
         self.add_path_resolution(
             ctx,
             path_ctx,
@@ -257,6 +269,9 @@ impl Completions {
         mac: hir::Macro,
         local_name: hir::Name,
     ) {
+        if !ctx.check_stability(Some(&mac.attrs(ctx.db))) {
+            return;
+        }
         let is_private_editable = match ctx.is_visible(&mac) {
             Visible::Yes => false,
             Visible::Editable => true,
@@ -280,6 +295,9 @@ impl Completions {
         func: hir::Function,
         local_name: Option<hir::Name>,
     ) {
+        if !ctx.check_stability(Some(&func.attrs(ctx.db))) {
+            return;
+        }
         let is_private_editable = match ctx.is_visible(&func) {
             Visible::Yes => false,
             Visible::Editable => true,
@@ -304,6 +322,9 @@ impl Completions {
         receiver: Option<hir::Name>,
         local_name: Option<hir::Name>,
     ) {
+        if !ctx.check_stability(Some(&func.attrs(ctx.db))) {
+            return;
+        }
         let is_private_editable = match ctx.is_visible(&func) {
             Visible::Yes => false,
             Visible::Editable => true,
@@ -328,6 +349,9 @@ impl Completions {
         func: hir::Function,
         import: LocatedImport,
     ) {
+        if !ctx.check_stability(Some(&func.attrs(ctx.db))) {
+            return;
+        }
         let is_private_editable = match ctx.is_visible(&func) {
             Visible::Yes => false,
             Visible::Editable => true,
@@ -348,6 +372,9 @@ impl Completions {
     }
 
     pub(crate) fn add_const(&mut self, ctx: &CompletionContext<'_>, konst: hir::Const) {
+        if !ctx.check_stability(Some(&konst.attrs(ctx.db))) {
+            return;
+        }
         let is_private_editable = match ctx.is_visible(&konst) {
             Visible::Yes => false,
             Visible::Editable => true,
@@ -364,6 +391,9 @@ impl Completions {
         ctx: &CompletionContext<'_>,
         type_alias: hir::TypeAlias,
     ) {
+        if !ctx.check_stability(Some(&type_alias.attrs(ctx.db))) {
+            return;
+        }
         let is_private_editable = match ctx.is_visible(&type_alias) {
             Visible::Yes => false,
             Visible::Editable => true,
@@ -380,6 +410,9 @@ impl Completions {
         ctx: &CompletionContext<'_>,
         type_alias: hir::TypeAlias,
     ) {
+        if !ctx.check_stability(Some(&type_alias.attrs(ctx.db))) {
+            return;
+        }
         self.add_opt(render_type_alias_with_eq(RenderContext::new(ctx), type_alias));
     }
 
@@ -390,6 +423,9 @@ impl Completions {
         variant: hir::Variant,
         path: hir::ModPath,
     ) {
+        if !ctx.check_stability(Some(&variant.attrs(ctx.db))) {
+            return;
+        }
         if let Some(builder) =
             render_variant_lit(RenderContext::new(ctx), path_ctx, None, variant, Some(path))
         {
@@ -404,6 +440,9 @@ impl Completions {
         variant: hir::Variant,
         local_name: Option<hir::Name>,
     ) {
+        if !ctx.check_stability(Some(&variant.attrs(ctx.db))) {
+            return;
+        }
         if let PathCompletionCtx { kind: PathKind::Pat { pat_ctx }, .. } = path_ctx {
             cov_mark::hit!(enum_variant_pattern_path);
             self.add_variant_pat(ctx, pat_ctx, Some(path_ctx), variant, local_name);
@@ -425,6 +464,9 @@ impl Completions {
         field: hir::Field,
         ty: &hir::Type,
     ) {
+        if !ctx.check_stability(Some(&field.attrs(ctx.db))) {
+            return;
+        }
         let is_private_editable = match ctx.is_visible(&field) {
             Visible::Yes => false,
             Visible::Editable => true,
@@ -448,6 +490,9 @@ impl Completions {
         path: Option<hir::ModPath>,
         local_name: Option<hir::Name>,
     ) {
+        if !ctx.check_stability(Some(&strukt.attrs(ctx.db))) {
+            return;
+        }
         if let Some(builder) =
             render_struct_literal(RenderContext::new(ctx), path_ctx, strukt, path, local_name)
         {
@@ -462,6 +507,9 @@ impl Completions {
         path: Option<hir::ModPath>,
         local_name: Option<hir::Name>,
     ) {
+        if !ctx.check_stability(Some(&un.attrs(ctx.db))) {
+            return;
+        }
         let item = render_union_literal(RenderContext::new(ctx), un, path, local_name);
         self.add_opt(item);
     }
@@ -473,6 +521,8 @@ impl Completions {
         field: usize,
         ty: &hir::Type,
     ) {
+        // Only used for (unnamed) tuples, whose all fields *are* stable. No need to check
+        // stability here.
         let item = render_tuple_field(RenderContext::new(ctx), receiver, field, ty);
         self.add(item);
     }
@@ -494,6 +544,9 @@ impl Completions {
         variant: hir::Variant,
         local_name: Option<hir::Name>,
     ) {
+        if !ctx.check_stability(Some(&variant.attrs(ctx.db))) {
+            return;
+        }
         self.add_opt(render_variant_pat(
             RenderContext::new(ctx),
             pattern_ctx,
@@ -511,6 +564,9 @@ impl Completions {
         variant: hir::Variant,
         path: hir::ModPath,
     ) {
+        if !ctx.check_stability(Some(&variant.attrs(ctx.db))) {
+            return;
+        }
         let path = Some(&path);
         self.add_opt(render_variant_pat(
             RenderContext::new(ctx),
@@ -529,6 +585,9 @@ impl Completions {
         strukt: hir::Struct,
         local_name: Option<hir::Name>,
     ) {
+        if !ctx.check_stability(Some(&strukt.attrs(ctx.db))) {
+            return;
+        }
         self.add_opt(render_struct_pat(RenderContext::new(ctx), pattern_ctx, strukt, local_name));
     }
 }
diff --git a/crates/ide-completion/src/completions/dot.rs b/crates/ide-completion/src/completions/dot.rs
index 77246379e7..ba76634016 100644
--- a/crates/ide-completion/src/completions/dot.rs
+++ b/crates/ide-completion/src/completions/dot.rs
@@ -172,6 +172,43 @@ fn foo(s: S) { s.$0 }
         );
     }
 
+    #[test]
+    fn no_unstable_method_on_stable() {
+        check(
+            r#"
+//- /main.rs crate:main deps:std
+fn foo(s: std::S) { s.$0 }
+//- /std.rs crate:std
+pub struct S;
+impl S {
+    #[unstable]
+    pub fn bar(&self) {}
+}
+"#,
+            expect![""],
+        );
+    }
+
+    #[test]
+    fn unstable_method_on_nightly() {
+        check(
+            r#"
+//- toolchain:nightly
+//- /main.rs crate:main deps:std
+fn foo(s: std::S) { s.$0 }
+//- /std.rs crate:std
+pub struct S;
+impl S {
+    #[unstable]
+    pub fn bar(&self) {}
+}
+"#,
+            expect![[r#"
+                me bar() fn(&self)
+            "#]],
+        );
+    }
+
     #[test]
     fn test_struct_field_completion_self() {
         check(
diff --git a/crates/ide-completion/src/completions/flyimport.rs b/crates/ide-completion/src/completions/flyimport.rs
index 0979f6a6df..32d3fb8c62 100644
--- a/crates/ide-completion/src/completions/flyimport.rs
+++ b/crates/ide-completion/src/completions/flyimport.rs
@@ -267,8 +267,10 @@ fn import_on_the_fly(
             .into_iter()
             .filter(ns_filter)
             .filter(|import| {
-                !ctx.is_item_hidden(&import.item_to_import)
-                    && !ctx.is_item_hidden(&import.original_item)
+                let item = &import.item_to_import;
+                !ctx.is_item_hidden(item)
+                    && !ctx.is_item_hidden(item)
+                    && ctx.check_stability(item.attrs(ctx.db).as_deref())
             })
             .sorted_by_key(|located_import| {
                 compute_fuzzy_completion_order_key(
@@ -315,8 +317,10 @@ fn import_on_the_fly_pat_(
             .into_iter()
             .filter(ns_filter)
             .filter(|import| {
-                !ctx.is_item_hidden(&import.item_to_import)
-                    && !ctx.is_item_hidden(&import.original_item)
+                let item = &import.item_to_import;
+                !ctx.is_item_hidden(item)
+                    && !ctx.is_item_hidden(item)
+                    && ctx.check_stability(item.attrs(ctx.db).as_deref())
             })
             .sorted_by_key(|located_import| {
                 compute_fuzzy_completion_order_key(
diff --git a/crates/ide-completion/src/completions/item_list/trait_impl.rs b/crates/ide-completion/src/completions/item_list/trait_impl.rs
index 889d90095f..e82908a361 100644
--- a/crates/ide-completion/src/completions/item_list/trait_impl.rs
+++ b/crates/ide-completion/src/completions/item_list/trait_impl.rs
@@ -150,21 +150,24 @@ fn complete_trait_impl(
     impl_def: &ast::Impl,
 ) {
     if let Some(hir_impl) = ctx.sema.to_def(impl_def) {
-        get_missing_assoc_items(&ctx.sema, impl_def).into_iter().for_each(|item| {
-            use self::ImplCompletionKind::*;
-            match (item, kind) {
-                (hir::AssocItem::Function(func), All | Fn) => {
-                    add_function_impl(acc, ctx, replacement_range, func, hir_impl)
+        get_missing_assoc_items(&ctx.sema, impl_def)
+            .into_iter()
+            .filter(|item| ctx.check_stability(Some(&item.attrs(ctx.db))))
+            .for_each(|item| {
+                use self::ImplCompletionKind::*;
+                match (item, kind) {
+                    (hir::AssocItem::Function(func), All | Fn) => {
+                        add_function_impl(acc, ctx, replacement_range, func, hir_impl)
+                    }
+                    (hir::AssocItem::TypeAlias(type_alias), All | TypeAlias) => {
+                        add_type_alias_impl(acc, ctx, replacement_range, type_alias, hir_impl)
+                    }
+                    (hir::AssocItem::Const(const_), All | Const) => {
+                        add_const_impl(acc, ctx, replacement_range, const_, hir_impl)
+                    }
+                    _ => {}
                 }
-                (hir::AssocItem::TypeAlias(type_alias), All | TypeAlias) => {
-                    add_type_alias_impl(acc, ctx, replacement_range, type_alias, hir_impl)
-                }
-                (hir::AssocItem::Const(const_), All | Const) => {
-                    add_const_impl(acc, ctx, replacement_range, const_, hir_impl)
-                }
-                _ => {}
-            }
-        });
+            });
     }
 }
 
diff --git a/crates/ide-completion/src/completions/use_.rs b/crates/ide-completion/src/completions/use_.rs
index 546a1f4c49..45be4fb205 100644
--- a/crates/ide-completion/src/completions/use_.rs
+++ b/crates/ide-completion/src/completions/use_.rs
@@ -52,6 +52,9 @@ pub(crate) fn complete_use_path(
                         )
                     };
                     for (name, def) in module_scope {
+                        if !ctx.check_stability(def.attrs(ctx.db).as_deref()) {
+                            continue;
+                        }
                         let is_name_already_imported = name
                             .as_text()
                             .map_or(false, |text| already_imported_names.contains(text.as_str()));
diff --git a/crates/ide-completion/src/context.rs b/crates/ide-completion/src/context.rs
index f6478d2ceb..d99414fe35 100644
--- a/crates/ide-completion/src/context.rs
+++ b/crates/ide-completion/src/context.rs
@@ -367,6 +367,8 @@ pub(crate) struct CompletionContext<'a> {
     pub(super) krate: hir::Crate,
     /// The module of the `scope`.
     pub(super) module: hir::Module,
+    /// Whether nightly toolchain is used. Cached since this is looked up a lot.
+    is_nightly: bool,
 
     /// The expected name of what we are completing.
     /// This is usually the parameter name of the function argument we are completing.
@@ -386,7 +388,7 @@ pub(crate) struct CompletionContext<'a> {
     pub(super) depth_from_crate_root: usize,
 }
 
-impl<'a> CompletionContext<'a> {
+impl CompletionContext<'_> {
     /// The range of the identifier that is being completed.
     pub(crate) fn source_range(&self) -> TextRange {
         let kind = self.original_token.kind();
@@ -451,6 +453,12 @@ impl<'a> CompletionContext<'a> {
         }
     }
 
+    /// Checks whether this item should be listed in regards to stability. Returns `true` if we should.
+    pub(crate) fn check_stability(&self, attrs: Option<&hir::Attrs>) -> bool {
+        let Some(attrs) = attrs else { return true; };
+        !attrs.is_unstable() || self.is_nightly
+    }
+
     /// Whether the given trait is an operator trait or not.
     pub(crate) fn is_ops_trait(&self, trait_: hir::Trait) -> bool {
         match trait_.attrs(self.db).lang() {
@@ -624,6 +632,11 @@ impl<'a> CompletionContext<'a> {
         let krate = scope.krate();
         let module = scope.module();
 
+        let toolchain = db.crate_graph()[krate.into()].channel;
+        // `toolchain == None` means we're in some detached files. Since we have no information on
+        // the toolchain being used, let's just allow unstable items to be listed.
+        let is_nightly = matches!(toolchain, Some(base_db::ReleaseChannel::Nightly) | None);
+
         let mut locals = FxHashMap::default();
         scope.process_all_names(&mut |name, scope| {
             if let ScopeDef::Local(local) = scope {
@@ -643,6 +656,7 @@ impl<'a> CompletionContext<'a> {
             token,
             krate,
             module,
+            is_nightly,
             expected_name,
             expected_type,
             qualifier_ctx,


Test patch:
diff --git a/crates/ide-completion/src/tests.rs b/crates/ide-completion/src/tests.rs
index 05e6aaf09d..79c1f98f3b 100644
--- a/crates/ide-completion/src/tests.rs
+++ b/crates/ide-completion/src/tests.rs
@@ -23,6 +23,7 @@ mod type_pos;
 mod use_tree;
 mod visibility;
 
+use expect_test::Expect;
 use hir::PrefixKind;
 use ide_db::{
     base_db::{fixture::ChangeFixture, FileLoader, FilePosition},
@@ -215,6 +216,11 @@ pub(crate) fn check_edit_with_config(
     assert_eq_text!(&ra_fixture_after, &actual)
 }
 
+fn check_empty(ra_fixture: &str, expect: Expect) {
+    let actual = completion_list(ra_fixture);
+    expect.assert_eq(&actual);
+}
+
 pub(crate) fn get_all_items(
     config: CompletionConfig,
     code: &str,
diff --git a/crates/ide-completion/src/tests/expression.rs b/crates/ide-completion/src/tests/expression.rs
index c1c6a689eb..36465be23d 100644
--- a/crates/ide-completion/src/tests/expression.rs
+++ b/crates/ide-completion/src/tests/expression.rs
@@ -1,18 +1,13 @@
 //! Completion tests for expressions.
 use expect_test::{expect, Expect};
 
-use crate::tests::{check_edit, completion_list, BASE_ITEMS_FIXTURE};
+use crate::tests::{check_edit, check_empty, completion_list, BASE_ITEMS_FIXTURE};
 
 fn check(ra_fixture: &str, expect: Expect) {
     let actual = completion_list(&format!("{BASE_ITEMS_FIXTURE}{ra_fixture}"));
     expect.assert_eq(&actual)
 }
 
-fn check_empty(ra_fixture: &str, expect: Expect) {
-    let actual = completion_list(ra_fixture);
-    expect.assert_eq(&actual);
-}
-
 #[test]
 fn complete_literal_struct_with_a_private_field() {
     // `FooDesc.bar` is private, the completion should not be triggered.
@@ -997,3 +992,105 @@ fn foo() { if foo {} el$0 { let x = 92; } }
         "#]],
     );
 }
+
+#[test]
+fn expr_no_unstable_item_on_stable() {
+    check_empty(
+        r#"
+//- /main.rs crate:main deps:std
+use std::*;
+fn main() {
+    $0
+}
+//- /std.rs crate:std
+#[unstable]
+pub struct UnstableThisShouldNotBeListed;
+"#,
+        expect![[r#"
+            fn main()      fn()
+            md std
+            bt u32
+            kw const
+            kw crate::
+            kw enum
+            kw extern
+            kw false
+            kw fn
+            kw for
+            kw if
+            kw if let
+            kw impl
+            kw let
+            kw loop
+            kw match
+            kw mod
+            kw return
+            kw self::
+            kw static
+            kw struct
+            kw trait
+            kw true
+            kw type
+            kw union
+            kw unsafe
+            kw use
+            kw while
+            kw while let
+            sn macro_rules
+            sn pd
+            sn ppd
+        "#]],
+    );
+}
+
+#[test]
+fn expr_unstable_item_on_nightly() {
+    check_empty(
+        r#"
+//- toolchain:nightly
+//- /main.rs crate:main deps:std
+use std::*;
+fn main() {
+    $0
+}
+//- /std.rs crate:std
+#[unstable]
+pub struct UnstableButWeAreOnNightlyAnyway;
+"#,
+        expect![[r#"
+            fn main()                 fn()
+            md std
+            st UnstableButWeAreOnNightlyAnyway
+            bt u32
+            kw const
+            kw crate::
+            kw enum
+            kw extern
+            kw false
+            kw fn
+            kw for
+            kw if
+            kw if let
+            kw impl
+            kw let
+            kw loop
+            kw match
+            kw mod
+            kw return
+            kw self::
+            kw static
+            kw struct
+            kw trait
+            kw true
+            kw type
+            kw union
+            kw unsafe
+            kw use
+            kw while
+            kw while let
+            sn macro_rules
+            sn pd
+            sn ppd
+        "#]],
+    );
+}
diff --git a/crates/ide-completion/src/tests/flyimport.rs b/crates/ide-completion/src/tests/flyimport.rs
index 0b485eb776..d727320b51 100644
--- a/crates/ide-completion/src/tests/flyimport.rs
+++ b/crates/ide-completion/src/tests/flyimport.rs
@@ -1107,6 +1107,41 @@ fn function() {
     );
 }
 
+#[test]
+fn flyimport_pattern_no_unstable_item_on_stable() {
+    check(
+        r#"
+//- /main.rs crate:main deps:std
+fn function() {
+    let foo$0
+}
+//- /std.rs crate:std
+#[unstable]
+pub struct FooStruct {}
+"#,
+        expect![""],
+    );
+}
+
+#[test]
+fn flyimport_pattern_unstable_item_on_nightly() {
+    check(
+        r#"
+//- toolchain:nightly
+//- /main.rs crate:main deps:std
+fn function() {
+    let foo$0
+}
+//- /std.rs crate:std
+#[unstable]
+pub struct FooStruct {}
+"#,
+        expect![[r#"
+            st FooStruct (use std::FooStruct)
+        "#]],
+    );
+}
+
 #[test]
 fn flyimport_item_name() {
     check(
diff --git a/crates/ide-completion/src/tests/item_list.rs b/crates/ide-completion/src/tests/item_list.rs
index 9fc731bb11..2b5b4dd773 100644
--- a/crates/ide-completion/src/tests/item_list.rs
+++ b/crates/ide-completion/src/tests/item_list.rs
@@ -1,7 +1,7 @@
 //! Completion tests for item list position.
 use expect_test::{expect, Expect};
 
-use crate::tests::{check_edit, completion_list, BASE_ITEMS_FIXTURE};
+use crate::tests::{check_edit, check_empty, completion_list, BASE_ITEMS_FIXTURE};
 
 fn check(ra_fixture: &str, expect: Expect) {
     let actual = completion_list(&format!("{BASE_ITEMS_FIXTURE}{ra_fixture}"));
@@ -297,6 +297,58 @@ impl Test for () {
     );
 }
 
+#[test]
+fn in_trait_impl_no_unstable_item_on_stable() {
+    check_empty(
+        r#"
+trait Test {
+    #[unstable]
+    type Type;
+    #[unstable]
+    const CONST: ();
+    #[unstable]
+    fn function();
+}
+
+impl Test for () {
+    $0
+}
+"#,
+        expect![[r#"
+            kw crate::
+            kw self::
+        "#]],
+    );
+}
+
+#[test]
+fn in_trait_impl_unstable_item_on_nightly() {
+    check_empty(
+        r#"
+//- toolchain:nightly
+trait Test {
+    #[unstable]
+    type Type;
+    #[unstable]
+    const CONST: ();
+    #[unstable]
+    fn function();
+}
+
+impl Test for () {
+    $0
+}
+"#,
+        expect![[r#"
+            ct const CONST: () =
+            fn fn function()
+            ta type Type =
+            kw crate::
+            kw self::
+        "#]],
+    );
+}
+
 #[test]
 fn after_unit_struct() {
     check(
diff --git a/crates/ide-completion/src/tests/pattern.rs b/crates/ide-completion/src/tests/pattern.rs
index c0e485c36f..8af6cce98f 100644
--- a/crates/ide-completion/src/tests/pattern.rs
+++ b/crates/ide-completion/src/tests/pattern.rs
@@ -1,12 +1,7 @@
 //! Completion tests for pattern position.
 use expect_test::{expect, Expect};
 
-use crate::tests::{check_edit, completion_list, BASE_ITEMS_FIXTURE};
-
-fn check_empty(ra_fixture: &str, expect: Expect) {
-    let actual = completion_list(ra_fixture);
-    expect.assert_eq(&actual)
-}
+use crate::tests::{check_edit, check_empty, completion_list, BASE_ITEMS_FIXTURE};
 
 fn check(ra_fixture: &str, expect: Expect) {
     let actual = completion_list(&format!("{BASE_ITEMS_FIXTURE}\n{ra_fixture}"));
@@ -742,3 +737,56 @@ fn f(x: EnumAlias<u8>) {
         "#]],
     );
 }
+
+#[test]
+fn pat_no_unstable_item_on_stable() {
+    check_empty(
+        r#"
+//- /main.rs crate:main deps:std
+use std::*;
+fn foo() {
+    let a$0
+}
+//- /std.rs crate:std
+#[unstable]
+pub struct S;
+#[unstable]
+pub enum Enum {
+    Variant
+}
+"#,
+        expect![[r#"
+            md std
+            kw mut
+            kw ref
+        "#]],
+    );
+}
+
+#[test]
+fn pat_unstable_item_on_nightly() {
+    check_empty(
+        r#"
+//- toolchain:nightly
+//- /main.rs crate:main deps:std
+use std::*;
+fn foo() {
+    let a$0
+}
+//- /std.rs crate:std
+#[unstable]
+pub struct S;
+#[unstable]
+pub enum Enum {
+    Variant
+}
+"#,
+        expect![[r#"
+            en Enum
+            md std
+            st S
+            kw mut
+            kw ref
+        "#]],
+    );
+}
diff --git a/crates/ide-completion/src/tests/predicate.rs b/crates/ide-completion/src/tests/predicate.rs
index 2656a4d545..789ad66345 100644
--- a/crates/ide-completion/src/tests/predicate.rs
+++ b/crates/ide-completion/src/tests/predicate.rs
@@ -1,7 +1,7 @@
 //! Completion tests for predicates and bounds.
 use expect_test::{expect, Expect};
 
-use crate::tests::{completion_list, BASE_ITEMS_FIXTURE};
+use crate::tests::{check_empty, completion_list, BASE_ITEMS_FIXTURE};
 
 fn check(ra_fixture: &str, expect: Expect) {
     let actual = completion_list(&format!("{BASE_ITEMS_FIXTURE}\n{ra_fixture}"));
@@ -129,3 +129,43 @@ impl Record {
         "#]],
     );
 }
+
+#[test]
+fn pred_no_unstable_item_on_stable() {
+    check_empty(
+        r#"
+//- /main.rs crate:main deps:std
+use std::*;
+struct Foo<T> where T: $0 {}
+//- /std.rs crate:std
+#[unstable]
+pub trait Trait {}
+"#,
+        expect![[r#"
+            md std
+            kw crate::
+            kw self::
+        "#]],
+    );
+}
+
+#[test]
+fn pred_unstable_item_on_nightly() {
+    check_empty(
+        r#"
+//- toolchain:nightly
+//- /main.rs crate:main deps:std
+use std::*;
+struct Foo<T> where T: $0 {}
+//- /std.rs crate:std
+#[unstable]
+pub trait Trait {}
+"#,
+        expect![[r#"
+            md std
+            tt Trait
+            kw crate::
+            kw self::
+        "#]],
+    );
+}
diff --git a/crates/ide-completion/src/tests/type_pos.rs b/crates/ide-completion/src/tests/type_pos.rs
index c3f4fb4d18..8cb1ff4a12 100644
--- a/crates/ide-completion/src/tests/type_pos.rs
+++ b/crates/ide-completion/src/tests/type_pos.rs
@@ -1,7 +1,7 @@
 //! Completion tests for type position.
 use expect_test::{expect, Expect};
 
-use crate::tests::{completion_list, BASE_ITEMS_FIXTURE};
+use crate::tests::{check_empty, completion_list, BASE_ITEMS_FIXTURE};
 
 fn check(ra_fixture: &str, expect: Expect) {
     let actual = completion_list(&format!("{BASE_ITEMS_FIXTURE}\n{ra_fixture}"));
@@ -669,3 +669,53 @@ fn f(t: impl MyTrait<Item1 = u8, Item2 = $0
         "#]],
     );
 }
+
+#[test]
+fn type_pos_no_unstable_type_on_stable() {
+    check_empty(
+        r#"
+//- /main.rs crate:main deps:std
+use std::*;
+struct Foo {
+    f: $0
+}
+//- /std.rs crate:std
+#[unstable]
+pub struct S;
+"#,
+        expect![[r#"
+            md std
+            sp Self
+            st Foo
+            bt u32
+            kw crate::
+            kw self::
+        "#]],
+    )
+}
+
+#[test]
+fn type_pos_unstable_type_on_nightly() {
+    check_empty(
+        r#"
+//- toolchain:nightly
+//- /main.rs crate:main deps:std
+use std::*;
+struct Foo {
+    f: $0
+}
+//- /std.rs crate:std
+#[unstable]
+pub struct S;
+"#,
+        expect![[r#"
+            md std
+            sp Self
+            st Foo
+            st S
+            bt u32
+            kw crate::
+            kw self::
+        "#]],
+    )
+}
diff --git a/crates/ide-completion/src/tests/use_tree.rs b/crates/ide-completion/src/tests/use_tree.rs
index 037d7dce52..ba2e047999 100644
--- a/crates/ide-completion/src/tests/use_tree.rs
+++ b/crates/ide-completion/src/tests/use_tree.rs
@@ -382,3 +382,51 @@ use self::foo::impl$0
         "#]],
     );
 }
+
+#[test]
+fn use_tree_no_unstable_items_on_stable() {
+    check(
+        r#"
+//- toolchain:stable
+//- /lib.rs crate:main deps:std
+use std::$0
+//- /std.rs crate:std
+#[unstable]
+pub mod simd {}
+#[unstable]
+pub struct S;
+#[unstable]
+pub fn foo() {}
+#[unstable]
+#[macro_export]
+marco_rules! m { () => {} }
+"#,
+        expect![""],
+    );
+}
+
+#[test]
+fn use_tree_unstable_items_on_nightly() {
+    check(
+        r#"
+//- toolchain:nightly
+//- /lib.rs crate:main deps:std
+use std::$0
+//- /std.rs crate:std
+#[unstable]
+pub mod simd {}
+#[unstable]
+pub struct S;
+#[unstable]
+pub fn foo() {}
+#[unstable]
+#[macro_export]
+marco_rules! m { () => {} }
+"#,
+        expect![[r#"
+            fn foo  fn()
+            md simd
+            st S
+        "#]],
+    );
+}
diff --git a/crates/ide/src/doc_links/tests.rs b/crates/ide/src/doc_links/tests.rs
index 104181a33e..b6b46c4508 100644
--- a/crates/ide/src/doc_links/tests.rs
+++ b/crates/ide/src/doc_links/tests.rs
@@ -116,7 +116,7 @@ fn external_docs_doc_url_std_crate() {
 //- /main.rs crate:std
 use self$0;
 "#,
-        expect![[r#"https://doc.rust-lang.org/nightly/std/index.html"#]],
+        expect!["https://doc.rust-lang.org/stable/std/index.html"],
     );
 }
 
diff --git a/crates/ide/src/hover/tests.rs b/crates/ide/src/hover/tests.rs
index 8a58fbeb86..73ab3d2e16 100644
--- a/crates/ide/src/hover/tests.rs
+++ b/crates/ide/src/hover/tests.rs
@@ -4242,7 +4242,7 @@ fn foo() {
 /// [threads]: ../book/ch16-01-threads.html#using-move-closures-with-threads
 mod move_keyword {}
 "#,
-        expect![[r##"
+        expect![[r#"
             *move*
 
             ```rust
@@ -4251,11 +4251,11 @@ mod move_keyword {}
 
             ---
 
-            [closure](https://doc.rust-lang.org/nightly/book/ch13-01-closures.html)
-            [closures](https://doc.rust-lang.org/nightly/book/ch13-01-closures.html)
-            [threads](https://doc.rust-lang.org/nightly/book/ch16-01-threads.html#using-move-closures-with-threads)
+            [closure](https://doc.rust-lang.org/stable/book/ch13-01-closures.html)
+            [closures](https://doc.rust-lang.org/stable/book/ch13-01-closures.html)
+            [threads](https://doc.rust-lang.org/stable/book/ch16-01-threads.html#using-move-closures-with-threads)
             <https://doc.rust-lang.org/nightly/book/ch13-01-closures.html>
-        "##]],
+        "#]],
     );
 }
 
diff --git a/crates/rust-analyzer/tests/slow-tests/support.rs b/crates/rust-analyzer/tests/slow-tests/support.rs
index e5f0e57d2a..df62dcd7dd 100644
--- a/crates/rust-analyzer/tests/slow-tests/support.rs
+++ b/crates/rust-analyzer/tests/slow-tests/support.rs
@@ -13,7 +13,7 @@ use project_model::ProjectManifest;
 use rust_analyzer::{config::Config, lsp_ext, main_loop};
 use serde::Serialize;
 use serde_json::{json, to_string_pretty, Value};
-use test_utils::Fixture;
+use test_utils::FixtureWithProjectMeta;
 use vfs::AbsPathBuf;
 
 use crate::testdir::TestDir;
@@ -84,10 +84,12 @@ impl<'a> Project<'a> {
             profile::init_from(crate::PROFILE);
         });
 
-        let (mini_core, proc_macros, fixtures) = Fixture::parse(self.fixture);
-        assert!(proc_macros.is_empty());
+        let FixtureWithProjectMeta { fixture, mini_core, proc_macro_names, toolchain } =
+            FixtureWithProjectMeta::parse(self.fixture);
+        assert!(proc_macro_names.is_empty());
         assert!(mini_core.is_none());
-        for entry in fixtures {
+        assert!(toolchain.is_none());
+        for entry in fixture {
             let path = tmp_dir.path().join(&entry.path['/'.len_utf8()..]);
             fs::create_dir_all(path.parent().unwrap()).unwrap();
             fs::write(path.as_path(), entry.text.as_bytes()).unwrap();
diff --git a/crates/test-utils/src/fixture.rs b/crates/test-utils/src/fixture.rs
index cd1235fa6d..dffc7fccda 100644
--- a/crates/test-utils/src/fixture.rs
+++ b/crates/test-utils/src/fixture.rs
@@ -86,7 +86,14 @@ pub struct MiniCore {
     valid_flags: Vec<String>,
 }
 
-impl Fixture {
+pub struct FixtureWithProjectMeta {
+    pub fixture: Vec<Fixture>,
+    pub mini_core: Option<MiniCore>,
+    pub proc_macro_names: Vec<String>,
+    pub toolchain: Option<String>,
+}
+
+impl FixtureWithProjectMeta {
     /// Parses text which looks like this:
     ///
     ///  ```not_rust
@@ -96,37 +103,40 @@ impl Fixture {
     ///  //- other meta
     ///  ```
     ///
-    /// Fixture can also start with a proc_macros and minicore declaration(in that order):
+    /// Fixture can also start with a proc_macros and minicore declaration (in that order):
     ///
     /// ```
+    /// //- toolchain: nightly
     /// //- proc_macros: identity
     /// //- minicore: sized
     /// ```
     ///
     /// That will include predefined proc macros and a subset of `libcore` into the fixture, see
     /// `minicore.rs` for what's available.
-    pub fn parse(ra_fixture: &str) -> (Option<MiniCore>, Vec<String>, Vec<Fixture>) {
+    pub fn parse(ra_fixture: &str) -> Self {
         let fixture = trim_indent(ra_fixture);
         let mut fixture = fixture.as_str();
+        let mut toolchain = None;
         let mut mini_core = None;
         let mut res: Vec<Fixture> = Vec::new();
-        let mut test_proc_macros = vec![];
-
-        if fixture.starts_with("//- proc_macros:") {
-            let first_line = fixture.split_inclusive('\n').next().unwrap();
-            test_proc_macros = first_line
-                .strip_prefix("//- proc_macros:")
-                .unwrap()
-                .split(',')
-                .map(|it| it.trim().to_string())
-                .collect();
-            fixture = &fixture[first_line.len()..];
+        let mut proc_macro_names = vec![];
+
+        if let Some(meta) = fixture.strip_prefix("//- toolchain:") {
+            let (meta, remain) = meta.split_once('\n').unwrap();
+            toolchain = Some(meta.trim().to_string());
+            fixture = remain;
         }
 
-        if fixture.starts_with("//- minicore:") {
-            let first_line = fixture.split_inclusive('\n').next().unwrap();
-            mini_core = Some(MiniCore::parse(first_line));
-            fixture = &fixture[first_line.len()..];
+        if let Some(meta) = fixture.strip_prefix("//- proc_macros:") {
+            let (meta, remain) = meta.split_once('\n').unwrap();
+            proc_macro_names = meta.split(',').map(|it| it.trim().to_string()).collect();
+            fixture = remain;
+        }
+
+        if let Some(meta) = fixture.strip_prefix("//- minicore:") {
+            let (meta, remain) = meta.split_once('\n').unwrap();
+            mini_core = Some(MiniCore::parse(meta));
+            fixture = remain;
         }
 
         let default = if fixture.contains("//-") { None } else { Some("//- /main.rs") };
@@ -142,7 +152,7 @@ impl Fixture {
             }
 
             if line.starts_with("//-") {
-                let meta = Fixture::parse_meta_line(line);
+                let meta = Self::parse_meta_line(line);
                 res.push(meta);
             } else {
                 if line.starts_with("// ")
@@ -160,7 +170,7 @@ impl Fixture {
             }
         }
 
-        (mini_core, test_proc_macros, res)
+        Self { fixture: res, mini_core, proc_macro_names, toolchain }
     }
 
     //- /lib.rs crate:foo deps:bar,baz cfg:foo=a,bar=b env:OUTDIR=path/to,OTHER=foo
@@ -257,8 +267,7 @@ impl MiniCore {
     fn parse(line: &str) -> MiniCore {
         let mut res = MiniCore { activated_flags: Vec::new(), valid_flags: Vec::new() };
 
-        let line = line.strip_prefix("//- minicore:").unwrap().trim();
-        for entry in line.split(", ") {
+        for entry in line.trim().split(", ") {
             if res.has_flag(entry) {
                 panic!("duplicate minicore flag: {entry:?}");
             }
@@ -372,7 +381,7 @@ impl MiniCore {
 #[test]
 #[should_panic]
 fn parse_fixture_checks_further_indented_metadata() {
-    Fixture::parse(
+    FixtureWithProjectMeta::parse(
         r"
         //- /lib.rs
           mod bar;
@@ -386,15 +395,18 @@ fn parse_fixture_checks_further_indented_metadata() {
 
 #[test]
 fn parse_fixture_gets_full_meta() {
-    let (mini_core, proc_macros, parsed) = Fixture::parse(
-        r#"
+    let FixtureWithProjectMeta { fixture: parsed, mini_core, proc_macro_names, toolchain } =
+        FixtureWithProjectMeta::parse(
+            r#"
+//- toolchain: nightly
 //- proc_macros: identity
 //- minicore: coerce_unsized
 //- /lib.rs crate:foo deps:bar,baz cfg:foo=a,bar=b,atom env:OUTDIR=path/to,OTHER=foo
 mod m;
 "#,
-    );
-    assert_eq!(proc_macros, vec!["identity".to_string()]);
+        );
+    assert_eq!(toolchain, Some("nightly".to_string()));
+    assert_eq!(proc_macro_names, vec!["identity".to_string()]);
     assert_eq!(mini_core.unwrap().activated_flags, vec!["coerce_unsized".to_string()]);
     assert_eq!(1, parsed.len());
 
diff --git a/crates/test-utils/src/lib.rs b/crates/test-utils/src/lib.rs
index a7a52e08e7..5abadaad62 100644
--- a/crates/test-utils/src/lib.rs
+++ b/crates/test-utils/src/lib.rs
@@ -27,7 +27,7 @@ pub use rustc_hash::FxHashMap;
 
 pub use crate::{
     assert_linear::AssertLinear,
-    fixture::{Fixture, MiniCore},
+    fixture::{Fixture, FixtureWithProjectMeta, MiniCore},
 };
 
 pub const CURSOR_MARKER: &str = "$0";