Repository: rust-analyzer/rust-analyzer
Pull request #4683 (2020-06-01 13:49:45)

Implement syntax highlighting for doctests
The implementation is more complicated than the previous injection logic as the doctest comments consist of multiple ranges. The implementation extracts the doctests together with an offset-mapping, applies the syntax highlighting, and updates the text ranges.

<img width="478" alt="Bildschirmfoto 2020-06-01 um 15 45 25" src="https://user-images.githubusercontent.com/201808/83415249-1f0b5800-a41f-11ea-8fa6-c282434d6ff7.png">

Part of #4170.

aloucks: Is it possible to both have the doctest examples highlighted but also have the coloring muted to some degree?

I really like having the examples highlighted in general. However, after IntelliJ added this feature, I found it more difficult to tell, at-a-glance, where comments end and real code starts. It's especially noticeable while scrolling through source files where the docs and examples are often larger than the actual function bodies. 




matklad: We certainly can add `TokenModifier::Documentation` to all highlights here. But it's up to the client/theme to interpret those somehow. i don't think there exist a generic dimming fascility 
ltentrup: I have updated the PR with the changes suggested above, let me know if I have missed something
bors[bot]: :v: ltentrup can now approve this pull request. To approve and merge a pull request, simply reply with `bors r+`. More detailed instructions are available [here](https://bors.tech/documentation/getting-started/#reviewing-pull-requests).

Patch:
diff --git a/crates/ra_ide/src/hover.rs b/crates/ra_ide/src/hover.rs
index ad78b76712..846d8c69ba 100644
--- a/crates/ra_ide/src/hover.rs
+++ b/crates/ra_ide/src/hover.rs
@@ -14,43 +14,34 @@ use ra_syntax::{ast, match_ast, AstNode, SyntaxKind::*, SyntaxToken, TokenAtOffs
 
 use crate::{
     display::{macro_label, rust_code_markup, rust_code_markup_with_doc, ShortLabel, ToNav},
-    runnables::runnable,
-    FileId, FilePosition, NavigationTarget, RangeInfo, Runnable,
+    FilePosition, NavigationTarget, RangeInfo,
 };
-use test_utils::mark;
 
 #[derive(Clone, Debug, PartialEq, Eq)]
 pub struct HoverConfig {
     pub implementations: bool,
-    pub run: bool,
-    pub debug: bool,
 }
 
 impl Default for HoverConfig {
     fn default() -> Self {
-        Self { implementations: true, run: true, debug: true }
+        Self { implementations: true }
     }
 }
 
 impl HoverConfig {
-    pub const NO_ACTIONS: Self = Self { implementations: false, run: false, debug: false };
+    pub const NO_ACTIONS: Self = Self { implementations: false };
 
     pub fn any(&self) -> bool {
-        self.implementations || self.runnable()
+        self.implementations
     }
 
     pub fn none(&self) -> bool {
         !self.any()
     }
-
-    pub fn runnable(&self) -> bool {
-        self.run || self.debug
-    }
 }
 
 #[derive(Debug, Clone)]
 pub enum HoverAction {
-    Runnable(Runnable),
     Implementaion(FilePosition),
 }
 
@@ -134,10 +125,6 @@ pub(crate) fn hover(db: &RootDatabase, position: FilePosition) -> Option<RangeIn
                 res.push_action(action);
             }
 
-            if let Some(action) = runnable_action(&sema, name_kind, position.file_id) {
-                res.push_action(action);
-            }
-
             return Some(RangeInfo::new(range, res));
         }
     }
@@ -188,36 +175,6 @@ fn show_implementations_action(db: &RootDatabase, def: Definition) -> Option<Hov
     }
 }
 
-fn runnable_action(
-    sema: &Semantics<RootDatabase>,
-    def: Definition,
-    file_id: FileId,
-) -> Option<HoverAction> {
-    match def {
-        Definition::ModuleDef(it) => match it {
-            ModuleDef::Module(it) => match it.definition_source(sema.db).value {
-                ModuleSource::Module(it) => runnable(&sema, it.syntax().clone(), file_id)
-                    .map(|it| HoverAction::Runnable(it)),
-                _ => None,
-            },
-            ModuleDef::Function(it) => {
-                let src = it.source(sema.db);
-                if src.file_id != file_id.into() {
-                    mark::hit!(hover_macro_generated_struct_fn_doc_comment);
-                    mark::hit!(hover_macro_generated_struct_fn_doc_attr);
-
-                    return None;
-                }
-
-                runnable(&sema, src.value.syntax().clone(), file_id)
-                    .map(|it| HoverAction::Runnable(it))
-            }
-            _ => None,
-        },
-        _ => None,
-    }
-}
-
 fn hover_text(
     docs: Option<String>,
     desc: Option<String>,
@@ -335,7 +292,6 @@ fn pick_best(tokens: TokenAtOffset<SyntaxToken>) -> Option<SyntaxToken> {
 #[cfg(test)]
 mod tests {
     use super::*;
-    use insta::assert_debug_snapshot;
 
     use ra_db::FileLoader;
     use ra_syntax::TextRange;
@@ -353,7 +309,6 @@ mod tests {
     fn assert_impl_action(action: &HoverAction, position: u32) {
         let offset = match action {
             HoverAction::Implementaion(pos) => pos.offset,
-            it => panic!("Unexpected hover action: {:#?}", it),
         };
         assert_eq!(offset, position.into());
     }
@@ -1121,8 +1076,6 @@ fn func(foo: i32) { if true { <|>foo; }; }
 
     #[test]
     fn test_hover_macro_generated_struct_fn_doc_comment() {
-        mark::check!(hover_macro_generated_struct_fn_doc_comment);
-
         check_hover_result(
             r#"
             //- /lib.rs
@@ -1149,8 +1102,6 @@ fn func(foo: i32) { if true { <|>foo; }; }
 
     #[test]
     fn test_hover_macro_generated_struct_fn_doc_attr() {
-        mark::check!(hover_macro_generated_struct_fn_doc_attr);
-
         check_hover_result(
             r#"
             //- /lib.rs
@@ -1225,89 +1176,4 @@ fn func(foo: i32) { if true { <|>foo; }; }
         );
         assert_impl_action(&actions[0], 5);
     }
-
-    #[test]
-    fn test_hover_test_has_action() {
-        let (_, actions) = check_hover_result(
-            "
-            //- /lib.rs
-            #[test]
-            fn foo_<|>test() {}
-            ",
-            &["fn foo_test()"],
-        );
-        assert_debug_snapshot!(actions,
-            @r###"
-            [
-                Runnable(
-                    Runnable {
-                        nav: NavigationTarget {
-                            file_id: FileId(
-                                1,
-                            ),
-                            full_range: 0..24,
-                            name: "foo_test",
-                            kind: FN_DEF,
-                            focus_range: Some(
-                                11..19,
-                            ),
-                            container_name: None,
-                            description: None,
-                            docs: None,
-                        },
-                        kind: Test {
-                            test_id: Path(
-                                "foo_test",
-                            ),
-                            attr: TestAttr {
-                                ignore: false,
-                            },
-                        },
-                        cfg_exprs: [],
-                    },
-                ),
-            ]
-            "###);
-    }
-
-    #[test]
-    fn test_hover_test_mod_has_action() {
-        let (_, actions) = check_hover_result(
-            "
-            //- /lib.rs
-            mod tests<|> {
-                #[test]
-                fn foo_test() {}
-            }
-            ",
-            &["mod tests"],
-        );
-        assert_debug_snapshot!(actions,
-            @r###"
-            [
-                Runnable(
-                    Runnable {
-                        nav: NavigationTarget {
-                            file_id: FileId(
-                                1,
-                            ),
-                            full_range: 0..46,
-                            name: "tests",
-                            kind: MODULE,
-                            focus_range: Some(
-                                4..9,
-                            ),
-                            container_name: None,
-                            description: None,
-                            docs: None,
-                        },
-                        kind: TestMod {
-                            path: "tests",
-                        },
-                        cfg_exprs: [],
-                    },
-                ),
-            ]
-            "###);
-    }
 }
diff --git a/crates/ra_ide/src/runnables.rs b/crates/ra_ide/src/runnables.rs
index fc57dc33d7..f32ce0d229 100644
--- a/crates/ra_ide/src/runnables.rs
+++ b/crates/ra_ide/src/runnables.rs
@@ -11,14 +11,14 @@ use ra_syntax::{
 
 use crate::{display::ToNav, FileId, NavigationTarget};
 
-#[derive(Debug, Clone)]
+#[derive(Debug)]
 pub struct Runnable {
     pub nav: NavigationTarget,
     pub kind: RunnableKind,
     pub cfg_exprs: Vec<CfgExpr>,
 }
 
-#[derive(Debug, Clone)]
+#[derive(Debug)]
 pub enum TestId {
     Name(String),
     Path(String),
@@ -33,7 +33,7 @@ impl fmt::Display for TestId {
     }
 }
 
-#[derive(Debug, Clone)]
+#[derive(Debug)]
 pub enum RunnableKind {
     Test { test_id: TestId, attr: TestAttr },
     TestMod { path: String },
@@ -42,42 +42,6 @@ pub enum RunnableKind {
     Bin,
 }
 
-#[derive(Debug, Eq, PartialEq)]
-pub struct RunnableAction {
-    pub run_title: &'static str,
-    pub debugee: bool,
-}
-
-const TEST: RunnableAction = RunnableAction { run_title: "▶\u{fe0e} Run Test", debugee: true };
-const DOCTEST: RunnableAction =
-    RunnableAction { run_title: "▶\u{fe0e} Run Doctest", debugee: false };
-const BENCH: RunnableAction = RunnableAction { run_title: "▶\u{fe0e} Run Bench", debugee: true };
-const BIN: RunnableAction = RunnableAction { run_title: "▶\u{fe0e} Run", debugee: true };
-
-impl Runnable {
-    // test package::module::testname
-    pub fn label(&self, target: Option<String>) -> String {
-        match &self.kind {
-            RunnableKind::Test { test_id, .. } => format!("test {}", test_id),
-            RunnableKind::TestMod { path } => format!("test-mod {}", path),
-            RunnableKind::Bench { test_id } => format!("bench {}", test_id),
-            RunnableKind::DocTest { test_id, .. } => format!("doctest {}", test_id),
-            RunnableKind::Bin => {
-                target.map_or_else(|| "run binary".to_string(), |t| format!("run {}", t))
-            }
-        }
-    }
-
-    pub fn action(&self) -> &'static RunnableAction {
-        match &self.kind {
-            RunnableKind::Test { .. } | RunnableKind::TestMod { .. } => &TEST,
-            RunnableKind::DocTest { .. } => &DOCTEST,
-            RunnableKind::Bench { .. } => &BENCH,
-            RunnableKind::Bin => &BIN,
-        }
-    }
-}
-
 // Feature: Run
 //
 // Shows a popup suggesting to run a test/benchmark/binary **at the current cursor
@@ -95,11 +59,7 @@ pub(crate) fn runnables(db: &RootDatabase, file_id: FileId) -> Vec<Runnable> {
     source_file.syntax().descendants().filter_map(|i| runnable(&sema, i, file_id)).collect()
 }
 
-pub(crate) fn runnable(
-    sema: &Semantics<RootDatabase>,
-    item: SyntaxNode,
-    file_id: FileId,
-) -> Option<Runnable> {
+fn runnable(sema: &Semantics<RootDatabase>, item: SyntaxNode, file_id: FileId) -> Option<Runnable> {
     match_ast! {
         match item {
             ast::FnDef(it) => runnable_fn(sema, it, file_id),
@@ -175,7 +135,7 @@ fn runnable_fn(
     Some(Runnable { nav, kind, cfg_exprs })
 }
 
-#[derive(Debug, Copy, Clone)]
+#[derive(Debug)]
 pub struct TestAttr {
     pub ignore: bool,
 }
@@ -247,15 +207,6 @@ mod tests {
 
     use crate::mock_analysis::analysis_and_position;
 
-    use super::{Runnable, RunnableAction, BENCH, BIN, DOCTEST, TEST};
-
-    fn assert_actions(runnables: &[Runnable], actions: &[&RunnableAction]) {
-        assert_eq!(
-            actions,
-            runnables.into_iter().map(|it| it.action()).collect::<Vec<_>>().as_slice()
-        );
-    }
-
     #[test]
     fn test_runnables() {
         let (analysis, pos) = analysis_and_position(
@@ -270,9 +221,6 @@ mod tests {
         #[test]
         #[ignore]
         fn test_foo() {}
-
-        #[bench]
-        fn bench() {}
         "#,
         );
         let runnables = analysis.runnables(pos.file_id).unwrap();
@@ -347,32 +295,9 @@ mod tests {
                 },
                 cfg_exprs: [],
             },
-            Runnable {
-                nav: NavigationTarget {
-                    file_id: FileId(
-                        1,
-                    ),
-                    full_range: 82..104,
-                    name: "bench",
-                    kind: FN_DEF,
-                    focus_range: Some(
-                        94..99,
-                    ),
-                    container_name: None,
-                    description: None,
-                    docs: None,
-                },
-                kind: Bench {
-                    test_id: Path(
-                        "bench",
-                    ),
-                },
-                cfg_exprs: [],
-            },
         ]
         "###
                 );
-        assert_actions(&runnables, &[&BIN, &TEST, &TEST, &BENCH]);
     }
 
     #[test]
@@ -436,7 +361,6 @@ mod tests {
         ]
         "###
                 );
-        assert_actions(&runnables, &[&BIN, &DOCTEST]);
     }
 
     #[test]
@@ -503,7 +427,6 @@ mod tests {
         ]
         "###
                 );
-        assert_actions(&runnables, &[&BIN, &DOCTEST]);
     }
 
     #[test]
@@ -570,7 +493,6 @@ mod tests {
         ]
         "###
                 );
-        assert_actions(&runnables, &[&TEST, &TEST]);
     }
 
     #[test]
@@ -639,7 +561,6 @@ mod tests {
         ]
         "###
                 );
-        assert_actions(&runnables, &[&TEST, &TEST]);
     }
 
     #[test]
@@ -710,7 +631,6 @@ mod tests {
         ]
         "###
                 );
-        assert_actions(&runnables, &[&TEST, &TEST]);
     }
 
     #[test]
@@ -761,7 +681,6 @@ mod tests {
         ]
         "###
                 );
-        assert_actions(&runnables, &[&TEST]);
     }
 
     #[test]
@@ -820,7 +739,6 @@ mod tests {
         ]
         "###
                 );
-        assert_actions(&runnables, &[&TEST]);
     }
 
     #[test]
diff --git a/crates/ra_ide/src/syntax_highlighting.rs b/crates/ra_ide/src/syntax_highlighting.rs
index 19ecd54d6c..6903403b2f 100644
--- a/crates/ra_ide/src/syntax_highlighting.rs
+++ b/crates/ra_ide/src/syntax_highlighting.rs
@@ -1,5 +1,6 @@
 mod tags;
 mod html;
+mod injection;
 #[cfg(test)]
 mod tests;
 
@@ -10,14 +11,14 @@ use ra_ide_db::{
 };
 use ra_prof::profile;
 use ra_syntax::{
-    ast::{self, HasFormatSpecifier, HasQuotes, HasStringValue},
+    ast::{self, HasFormatSpecifier},
     AstNode, AstToken, Direction, NodeOrToken, SyntaxElement,
     SyntaxKind::*,
-    SyntaxToken, TextRange, WalkEvent, T,
+    TextRange, WalkEvent, T,
 };
 use rustc_hash::FxHashMap;
 
-use crate::{call_info::ActiveParameter, Analysis, FileId};
+use crate::FileId;
 
 use ast::FormatSpecifier;
 pub(crate) use html::highlight_as_html;
@@ -123,6 +124,23 @@ pub(crate) fn highlight(
             _ => (),
         }
 
+        // Check for Rust code in documentation
+        match &event {
+            WalkEvent::Leave(NodeOrToken::Node(node)) => {
+                if let Some((doctest, range_mapping, new_comments)) =
+                    injection::extract_doc_comments(node)
+                {
+                    injection::highlight_doc_comment(
+                        doctest,
+                        range_mapping,
+                        new_comments,
+                        &mut stack,
+                    );
+                }
+            }
+            _ => (),
+        }
+
         let element = match event {
             WalkEvent::Enter(it) => it,
             WalkEvent::Leave(_) => continue,
@@ -173,7 +191,7 @@ pub(crate) fn highlight(
 
         if let Some(token) = element.as_token().cloned().and_then(ast::RawString::cast) {
             let expanded = element_to_highlight.as_token().unwrap().clone();
-            if highlight_injection(&mut stack, &sema, token, expanded).is_some() {
+            if injection::highlight_injection(&mut stack, &sema, token, expanded).is_some() {
                 continue;
             }
         }
@@ -259,9 +277,8 @@ impl HighlightedRangeStack {
             let mut parent = prev.pop().unwrap();
             for ele in children {
                 assert!(parent.range.contains_range(ele.range));
-                let mut cloned = parent.clone();
-                parent.range = TextRange::new(parent.range.start(), ele.range.start());
-                cloned.range = TextRange::new(ele.range.end(), cloned.range.end());
+
+                let cloned = Self::intersect(&mut parent, &ele);
                 if !parent.range.is_empty() {
                     prev.push(parent);
                 }
@@ -274,6 +291,62 @@ impl HighlightedRangeStack {
         }
     }
 
+    /// Intersects the `HighlightedRange` `parent` with `child`.
+    /// `parent` is mutated in place, becoming the range before `child`.
+    /// Returns the range (of the same type as `parent`) *after* `child`.
+    fn intersect(parent: &mut HighlightedRange, child: &HighlightedRange) -> HighlightedRange {
+        assert!(parent.range.contains_range(child.range));
+
+        let mut cloned = parent.clone();
+        parent.range = TextRange::new(parent.range.start(), child.range.start());
+        cloned.range = TextRange::new(child.range.end(), cloned.range.end());
+
+        cloned
+    }
+
+    /// Similar to `pop`, but can modify arbitrary prior ranges (where `pop`)
+    /// can only modify the last range currently on the stack.
+    /// Can be used to do injections that span multiple ranges, like the
+    /// doctest injection below.
+    /// If `delete` is set to true, the parent range is deleted instead of
+    /// intersected.
+    ///
+    /// Note that `pop` can be simulated by `pop_and_inject(false)` but the
+    /// latter is computationally more expensive.
+    fn pop_and_inject(&mut self, delete: bool) {
+        let mut children = self.stack.pop().unwrap();
+        let prev = self.stack.last_mut().unwrap();
+        children.sort_by_key(|range| range.range.start());
+        prev.sort_by_key(|range| range.range.start());
+
+        for child in children {
+            if let Some(idx) =
+                prev.iter().position(|parent| parent.range.contains_range(child.range))
+            {
+                let cloned = Self::intersect(&mut prev[idx], &child);
+                let insert_idx = if delete || prev[idx].range.is_empty() {
+                    prev.remove(idx);
+                    idx
+                } else {
+                    idx + 1
+                };
+                prev.insert(insert_idx, child);
+                if !delete && !cloned.range.is_empty() {
+                    prev.insert(insert_idx + 1, cloned);
+                }
+            } else if let Some(_idx) =
+                prev.iter().position(|parent| parent.range.contains(child.range.start()))
+            {
+                unreachable!("child range should be completely contained in parent range");
+            } else {
+                let idx = prev
+                    .binary_search_by_key(&child.range.start(), |range| range.range.start())
+                    .unwrap_or_else(|x| x);
+                prev.insert(idx, child);
+            }
+        }
+    }
+
     fn add(&mut self, range: HighlightedRange) {
         self.stack
             .last_mut()
@@ -539,42 +612,3 @@ fn highlight_name_by_syntax(name: ast::Name) -> Highlight {
 
     tag.into()
 }
-
-fn highlight_injection(
-    acc: &mut HighlightedRangeStack,
-    sema: &Semantics<RootDatabase>,
-    literal: ast::RawString,
-    expanded: SyntaxToken,
-) -> Option<()> {
-    let active_parameter = ActiveParameter::at_token(&sema, expanded)?;
-    if !active_parameter.name.starts_with("ra_fixture") {
-        return None;
-    }
-    let value = literal.value()?;
-    let (analysis, tmp_file_id) = Analysis::from_single_file(value);
-
-    if let Some(range) = literal.open_quote_text_range() {
-        acc.add(HighlightedRange {
-            range,
-            highlight: HighlightTag::StringLiteral.into(),
-            binding_hash: None,
-        })
-    }
-
-    for mut h in analysis.highlight(tmp_file_id).unwrap() {
-        if let Some(r) = literal.map_range_up(h.range) {
-            h.range = r;
-            acc.add(h)
-        }
-    }
-
-    if let Some(range) = literal.close_quote_text_range() {
-        acc.add(HighlightedRange {
-            range,
-            highlight: HighlightTag::StringLiteral.into(),
-            binding_hash: None,
-        })
-    }
-
-    Some(())
-}
diff --git a/crates/ra_ide/src/syntax_highlighting/injection.rs b/crates/ra_ide/src/syntax_highlighting/injection.rs
new file mode 100644
index 0000000000..3575a0fc6e
--- /dev/null
+++ b/crates/ra_ide/src/syntax_highlighting/injection.rs
@@ -0,0 +1,168 @@
+//! Syntax highlighting injections such as highlighting of documentation tests.
+
+use std::{collections::BTreeMap, convert::TryFrom};
+
+use ast::{HasQuotes, HasStringValue};
+use hir::Semantics;
+use ra_syntax::{ast, AstToken, SyntaxNode, SyntaxToken, TextRange, TextSize};
+use stdx::SepBy;
+
+use crate::{call_info::ActiveParameter, Analysis, HighlightTag, HighlightedRange, RootDatabase};
+
+use super::HighlightedRangeStack;
+
+pub(super) fn highlight_injection(
+    acc: &mut HighlightedRangeStack,
+    sema: &Semantics<RootDatabase>,
+    literal: ast::RawString,
+    expanded: SyntaxToken,
+) -> Option<()> {
+    let active_parameter = ActiveParameter::at_token(&sema, expanded)?;
+    if !active_parameter.name.starts_with("ra_fixture") {
+        return None;
+    }
+    let value = literal.value()?;
+    let (analysis, tmp_file_id) = Analysis::from_single_file(value);
+
+    if let Some(range) = literal.open_quote_text_range() {
+        acc.add(HighlightedRange {
+            range,
+            highlight: HighlightTag::StringLiteral.into(),
+            binding_hash: None,
+        })
+    }
+
+    for mut h in analysis.highlight(tmp_file_id).unwrap() {
+        if let Some(r) = literal.map_range_up(h.range) {
+            h.range = r;
+            acc.add(h)
+        }
+    }
+
+    if let Some(range) = literal.close_quote_text_range() {
+        acc.add(HighlightedRange {
+            range,
+            highlight: HighlightTag::StringLiteral.into(),
+            binding_hash: None,
+        })
+    }
+
+    Some(())
+}
+
+/// Mapping from extracted documentation code to original code
+type RangesMap = BTreeMap<TextSize, TextSize>;
+
+/// Extracts Rust code from documentation comments as well as a mapping from
+/// the extracted source code back to the original source ranges.
+/// Lastly, a vector of new comment highlight ranges (spanning only the
+/// comment prefix) is returned which is used in the syntax highlighting
+/// injection to replace the previous (line-spanning) comment ranges.
+pub(super) fn extract_doc_comments(
+    node: &SyntaxNode,
+) -> Option<(String, RangesMap, Vec<HighlightedRange>)> {
+    // wrap the doctest into function body to get correct syntax highlighting
+    let prefix = "fn doctest() {\n";
+    let suffix = "}\n";
+    // Mapping from extracted documentation code to original code
+    let mut range_mapping: RangesMap = BTreeMap::new();
+    let mut line_start = TextSize::try_from(prefix.len()).unwrap();
+    let mut is_doctest = false;
+    // Replace the original, line-spanning comment ranges by new, only comment-prefix
+    // spanning comment ranges.
+    let mut new_comments = Vec::new();
+    let doctest = node
+        .children_with_tokens()
+        .filter_map(|el| el.into_token().and_then(ast::Comment::cast))
+        .filter(|comment| comment.kind().doc.is_some())
+        .filter(|comment| {
+            if comment.text().contains("```") {
+                is_doctest = !is_doctest;
+                false
+            } else {
+                is_doctest
+            }
+        })
+        .map(|comment| {
+            let prefix_len = comment.prefix().len();
+            let line: &str = comment.text().as_str();
+            let range = comment.syntax().text_range();
+
+            // whitespace after comment is ignored
+            let pos = if let Some(ws) = line.chars().nth(prefix_len).filter(|c| c.is_whitespace()) {
+                prefix_len + ws.len_utf8()
+            } else {
+                prefix_len
+            };
+
+            // lines marked with `#` should be ignored in output, we skip the `#` char
+            let pos = if let Some(ws) = line.chars().nth(pos).filter(|&c| c == '#') {
+                pos + ws.len_utf8()
+            } else {
+                pos
+            };
+
+            range_mapping.insert(line_start, range.start() + TextSize::try_from(pos).unwrap());
+            new_comments.push(HighlightedRange {
+                range: TextRange::new(
+                    range.start(),
+                    range.start() + TextSize::try_from(pos).unwrap(),
+                ),
+                highlight: HighlightTag::Comment.into(),
+                binding_hash: None,
+            });
+            line_start += range.len() - TextSize::try_from(pos).unwrap();
+            line_start += TextSize::try_from('\n'.len_utf8()).unwrap();
+
+            line[pos..].to_owned()
+        })
+        .sep_by("\n")
+        .to_string();
+
+    if doctest.is_empty() {
+        return None;
+    }
+
+    let doctest = format!("{}{}{}", prefix, doctest, suffix);
+    Some((doctest, range_mapping, new_comments))
+}
+
+/// Injection of syntax highlighting of doctests.
+pub(super) fn highlight_doc_comment(
+    text: String,
+    range_mapping: RangesMap,
+    new_comments: Vec<HighlightedRange>,
+    stack: &mut HighlightedRangeStack,
+) {
+    let (analysis, tmp_file_id) = Analysis::from_single_file(text);
+
+    stack.push();
+    for mut h in analysis.highlight(tmp_file_id).unwrap() {
+        // Determine start offset and end offset in case of multi-line ranges
+        let mut start_offset = None;
+        let mut end_offset = None;
+        for (line_start, orig_line_start) in range_mapping.range(..h.range.end()).rev() {
+            if line_start <= &h.range.start() {
+                start_offset.get_or_insert(orig_line_start - line_start);
+                break;
+            } else {
+                end_offset.get_or_insert(orig_line_start - line_start);
+            }
+        }
+        if let Some(start_offset) = start_offset {
+            h.range = TextRange::new(
+                h.range.start() + start_offset,
+                h.range.end() + end_offset.unwrap_or(start_offset),
+            );
+            stack.add(h);
+        }
+    }
+
+    // Inject the comment prefix highlight ranges
+    stack.push();
+    for comment in new_comments {
+        stack.add(comment);
+    }
+    stack.pop_and_inject(false);
+    stack.pop_and_inject(true);
+}
diff --git a/crates/rust-analyzer/src/config.rs b/crates/rust-analyzer/src/config.rs
index 17671f89ee..8d6efdbe8c 100644
--- a/crates/rust-analyzer/src/config.rs
+++ b/crates/rust-analyzer/src/config.rs
@@ -285,8 +285,6 @@ impl Config {
         set(value, "/hoverActions/enable", &mut use_hover_actions);
         if use_hover_actions {
             set(value, "/hoverActions/implementations", &mut self.hover.implementations);
-            set(value, "/hoverActions/run", &mut self.hover.run);
-            set(value, "/hoverActions/debug", &mut self.hover.debug);
         } else {
             self.hover = HoverConfig::NO_ACTIONS;
         }
diff --git a/crates/rust-analyzer/src/main_loop/handlers.rs b/crates/rust-analyzer/src/main_loop/handlers.rs
index a41adf8b0b..3ff7797024 100644
--- a/crates/rust-analyzer/src/main_loop/handlers.rs
+++ b/crates/rust-analyzer/src/main_loop/handlers.rs
@@ -18,8 +18,8 @@ use lsp_types::{
     TextDocumentIdentifier, Url, WorkspaceEdit,
 };
 use ra_ide::{
-    FileId, FilePosition, FileRange, HoverAction, Query, RangeInfo, Runnable, RunnableKind,
-    SearchScope, TextEdit,
+    FileId, FilePosition, FileRange, HoverAction, Query, RangeInfo, RunnableKind, SearchScope,
+    TextEdit,
 };
 use ra_prof::profile;
 use ra_project_model::TargetKind;
@@ -404,10 +404,15 @@ pub fn handle_runnables(
                 continue;
             }
         }
-        if should_skip_target(&runnable, cargo_spec.as_ref()) {
-            continue;
+        // Do not suggest binary run on other target than binary
+        if let RunnableKind::Bin = runnable.kind {
+            if let Some(spec) = &cargo_spec {
+                match spec.target_kind {
+                    TargetKind::Bin => {}
+                    _ => continue,
+                }
+            }
         }
-
         res.push(to_proto::runnable(&snap, file_id, runnable)?);
     }
 
@@ -550,7 +555,7 @@ pub fn handle_hover(
             }),
             range: Some(range),
         },
-        actions: prepare_hover_actions(&snap, position.file_id, info.info.actions()),
+        actions: prepare_hover_actions(&snap, info.info.actions()),
     };
 
     Ok(Some(hover))
@@ -812,25 +817,55 @@ pub fn handle_code_lens(
     if snap.config.lens.runnable() {
         // Gather runnables
         for runnable in snap.analysis().runnables(file_id)? {
-            if should_skip_target(&runnable, cargo_spec.as_ref()) {
-                continue;
-            }
+            let (run_title, debugee) = match &runnable.kind {
+                RunnableKind::Test { .. } | RunnableKind::TestMod { .. } => {
+                    ("▶\u{fe0e} Run Test", true)
+                }
+                RunnableKind::DocTest { .. } => {
+                    // cargo does not support -no-run for doctests
+                    ("▶\u{fe0e} Run Doctest", false)
+                }
+                RunnableKind::Bench { .. } => {
+                    // Nothing wrong with bench debugging
+                    ("Run Bench", true)
+                }
+                RunnableKind::Bin => {
+                    // Do not suggest binary run on other target than binary
+                    match &cargo_spec {
+                        Some(spec) => match spec.target_kind {
+                            TargetKind::Bin => ("Run", true),
+                            _ => continue,
+                        },
+                        None => continue,
+                    }
+                }
+            };
 
-            let action = runnable.action();
             let range = to_proto::range(&line_index, runnable.nav.range());
             let r = to_proto::runnable(&snap, file_id, runnable)?;
             if snap.config.lens.run {
                 let lens = CodeLens {
                     range,
-                    command: Some(run_single_command(&r, action.run_title)),
+                    command: Some(Command {
+                        title: run_title.to_string(),
+                        command: "rust-analyzer.runSingle".into(),
+                        arguments: Some(vec![to_value(&r).unwrap()]),
+                    }),
                     data: None,
                 };
                 lenses.push(lens);
             }
 
-            if action.debugee && snap.config.lens.debug {
-                let debug_lens =
-                    CodeLens { range, command: Some(debug_single_command(&r)), data: None };
+            if debugee && snap.config.lens.debug {
+                let debug_lens = CodeLens {
+                    range,
+                    command: Some(Command {
+                        title: "Debug".into(),
+                        command: "rust-analyzer.debugSingle".into(),
+                        arguments: Some(vec![to_value(r).unwrap()]),
+                    }),
+                    data: None,
+                };
                 lenses.push(debug_lens);
             }
         }
@@ -1134,22 +1169,6 @@ fn show_references_command(
     }
 }
 
-fn run_single_command(runnable: &lsp_ext::Runnable, title: &str) -> Command {
-    Command {
-        title: title.to_string(),
-        command: "rust-analyzer.runSingle".into(),
-        arguments: Some(vec![to_value(runnable).unwrap()]),
-    }
-}
-
-fn debug_single_command(runnable: &lsp_ext::Runnable) -> Command {
-    Command {
-        title: "Debug".into(),
-        command: "rust-analyzer.debugSingle".into(),
-        arguments: Some(vec![to_value(runnable).unwrap()]),
-    }
-}
-
 fn to_command_link(command: Command, tooltip: String) -> lsp_ext::CommandLink {
     lsp_ext::CommandLink { tooltip: Some(tooltip), command }
 }
@@ -1180,37 +1199,8 @@ fn show_impl_command_link(
     None
 }
 
-fn to_runnable_action(
-    snap: &GlobalStateSnapshot,
-    file_id: FileId,
-    runnable: Runnable,
-) -> Option<lsp_ext::CommandLinkGroup> {
-    let cargo_spec = CargoTargetSpec::for_file(&snap, file_id).ok()?;
-    if should_skip_target(&runnable, cargo_spec.as_ref()) {
-        return None;
-    }
-
-    let action: &'static _ = runnable.action();
-    to_proto::runnable(snap, file_id, runnable).ok().map(|r| {
-        let mut group = lsp_ext::CommandLinkGroup::default();
-
-        if snap.config.hover.run {
-            let run_command = run_single_command(&r, action.run_title);
-            group.commands.push(to_command_link(run_command, r.label.clone()));
-        }
-
-        if snap.config.hover.debug {
-            let dbg_command = debug_single_command(&r);
-            group.commands.push(to_command_link(dbg_command, r.label));
-        }
-
-        group
-    })
-}
-
 fn prepare_hover_actions(
     snap: &GlobalStateSnapshot,
-    file_id: FileId,
     actions: &[HoverAction],
 ) -> Vec<lsp_ext::CommandLinkGroup> {
     if snap.config.hover.none() || !snap.config.client_caps.hover_actions {
@@ -1221,20 +1211,6 @@ fn prepare_hover_actions(
         .iter()
         .filter_map(|it| match it {
             HoverAction::Implementaion(position) => show_impl_command_link(snap, position),
-            HoverAction::Runnable(r) => to_runnable_action(snap, file_id, r.clone()),
         })
         .collect()
 }
-
-fn should_skip_target(runnable: &Runnable, cargo_spec: Option<&CargoTargetSpec>) -> bool {
-    match runnable.kind {
-        RunnableKind::Bin => {
-            // Do not suggest binary run on other target than binary
-            match &cargo_spec {
-                Some(spec) => spec.target_kind != TargetKind::Bin,
-                None => true,
-            }
-        }
-        _ => false,
-    }
-}
diff --git a/crates/rust-analyzer/src/to_proto.rs b/crates/rust-analyzer/src/to_proto.rs
index 710df1fbde..1da4d80ece 100644
--- a/crates/rust-analyzer/src/to_proto.rs
+++ b/crates/rust-analyzer/src/to_proto.rs
@@ -4,7 +4,7 @@ use ra_ide::{
     Assist, CompletionItem, CompletionItemKind, Documentation, FileSystemEdit, Fold, FoldKind,
     FunctionSignature, Highlight, HighlightModifier, HighlightTag, HighlightedRange, Indel,
     InlayHint, InlayKind, InsertTextFormat, LineIndex, NavigationTarget, ReferenceAccess,
-    ResolvedAssist, Runnable, Severity, SourceChange, SourceFileEdit, TextEdit,
+    ResolvedAssist, Runnable, RunnableKind, Severity, SourceChange, SourceFileEdit, TextEdit,
 };
 use ra_syntax::{SyntaxKind, TextRange, TextSize};
 use ra_vfs::LineEndings;
@@ -662,7 +662,15 @@ pub(crate) fn runnable(
     let target = spec.as_ref().map(|s| s.target.clone());
     let (cargo_args, executable_args) =
         CargoTargetSpec::runnable_args(spec, &runnable.kind, &runnable.cfg_exprs)?;
-    let label = runnable.label(target);
+    let label = match &runnable.kind {
+        RunnableKind::Test { test_id, .. } => format!("test {}", test_id),
+        RunnableKind::TestMod { path } => format!("test-mod {}", path),
+        RunnableKind::Bench { test_id } => format!("bench {}", test_id),
+        RunnableKind::DocTest { test_id, .. } => format!("doctest {}", test_id),
+        RunnableKind::Bin => {
+            target.map_or_else(|| "run binary".to_string(), |t| format!("run {}", t))
+        }
+    };
     let location = location_link(snap, None, runnable.nav)?;
 
     Ok(lsp_ext::Runnable {
diff --git a/docs/dev/README.md b/docs/dev/README.md
index 46ee030fc6..903cb4055a 100644
--- a/docs/dev/README.md
+++ b/docs/dev/README.md
@@ -241,34 +241,6 @@ struct Foo {
 For `.md` and `.adoc` files, prefer a sentence-per-line format, don't wrap lines.
 If the line is too long, you want to split the sentence in two :-)
 
-## Preconditions
-
-Function preconditions should generally be expressed in types and provided by the caller (rather than checked by callee):
-
-```rust
-// Good
-fn frbonicate(walrus: Walrus) {
-  ...
-}
-
-// Not as good
-fn frobnicate(walrus: Option<Walrus>) {
-  let walrus = match walrus {
-    Some(it) => it,
-    None => return,
-  };
-  ...
-}
-```
-
-## Commit Style
-
-We don't have specific rules around git history hygiene.
-Maintaining clean git history is encouraged, but not enforced.
-We use rebase workflow, it's OK to rewrite history during PR review process.
-
-Avoid @mentioning people in commit messages, as such messages create a lot of duplicate notification traffic during rebases.
-
 # Architecture Invariants
 
 This section tries to document high-level design constraints, which are not
@@ -296,13 +268,6 @@ IDE assumes that all information is available at all times.
 IDE should use only types from `ra_hir`, and should not depend on the underling compiler types.
 `ra_hir` is a facade.
 
-## IDE API
-
-The main IDE crate (`ra_ide`) uses "Plain Old Data" for the API.
-Rather than talking in definitions and references, it talks in Strings and textual offsets.
-In general, API is centered around UI concerns -- the result of the call is what the user sees in the editor, and not what the compiler sees underneath.
-The results are 100% Rust specific though.
-
 # Logging
 
 Logging is done by both rust-analyzer and VS Code, so it might be tricky to
diff --git a/editors/code/package.json b/editors/code/package.json
index 779d7e1b87..859ab44772 100644
--- a/editors/code/package.json
+++ b/editors/code/package.json
@@ -486,16 +486,6 @@
                     "type": "boolean",
                     "default": true
                 },
-                "rust-analyzer.hoverActions.run": {
-                    "markdownDescription": "Whether to show `Run` action. Only applies when `#rust-analyzer.hoverActions.enable#` is set.",
-                    "type": "boolean",
-                    "default": true
-                },
-                "rust-analyzer.hoverActions.debug": {
-                    "markdownDescription": "Whether to show `Debug` action. Only applies when `#rust-analyzer.hoverActions.enable#` is set.",
-                    "type": "boolean",
-                    "default": true
-                },
                 "rust-analyzer.linkedProjects": {
                     "markdownDescription": "Disable project auto-discovery in favor of explicitly specified set of projects.  \nElements must be paths pointing to Cargo.toml, rust-project.json, or JSON objects in rust-project.json format",
                     "type": "array",
diff --git a/xtask/src/lib.rs b/xtask/src/lib.rs
index 747654c1fc..739f49f7be 100644
--- a/xtask/src/lib.rs
+++ b/xtask/src/lib.rs
@@ -4,7 +4,6 @@
 
 pub mod not_bash;
 pub mod install;
-pub mod release;
 pub mod dist;
 pub mod pre_commit;
 
@@ -20,7 +19,7 @@ use walkdir::{DirEntry, WalkDir};
 
 use crate::{
     codegen::Mode,
-    not_bash::{fs2, pushd, pushenv, rm_rf, run},
+    not_bash::{date_iso, fs2, pushd, pushenv, rm_rf, run},
 };
 
 pub use anyhow::{bail, Context as _, Result};
@@ -154,6 +153,60 @@ pub fn run_pre_cache() -> Result<()> {
     Ok(())
 }
 
+pub fn run_release(dry_run: bool) -> Result<()> {
+    if !dry_run {
+        run!("git switch release")?;
+        run!("git fetch upstream --tags --force")?;
+        run!("git reset --hard tags/nightly")?;
+        run!("git push")?;
+    }
+    codegen::generate_assists_docs(Mode::Overwrite)?;
+    codegen::generate_feature_docs(Mode::Overwrite)?;
+
+    let website_root = project_root().join("../rust-analyzer.github.io");
+    let changelog_dir = website_root.join("./thisweek/_posts");
+
+    let today = date_iso()?;
+    let commit = run!("git rev-parse HEAD")?;
+    let changelog_n = fs2::read_dir(changelog_dir.as_path())?.count();
+
+    let contents = format!(
+        "\
+= Changelog #{}
+:sectanchors:
+:page-layout: post
+
+Commit: commit:{}[] +
+Release: release:{}[]
+
+== New Features
+
+* pr:[] .
+
+== Fixes
+
+== Internal Improvements
+",
+        changelog_n, commit, today
+    );
+
+    let path = changelog_dir.join(format!("{}-changelog-{}.adoc", today, changelog_n));
+    fs2::write(&path, &contents)?;
+
+    for &adoc in ["manual.adoc", "generated_features.adoc", "generated_assists.adoc"].iter() {
+        let src = project_root().join("./docs/user/").join(adoc);
+        let dst = website_root.join(adoc);
+        fs2::copy(src, dst)?;
+    }
+
+    let tags = run!("git tag --list"; echo = false)?;
+    let prev_tag = tags.lines().filter(|line| is_release_tag(line)).last().unwrap();
+
+    println!("\n    git log {}..HEAD --merges --reverse", prev_tag);
+
+    Ok(())
+}
+
 fn is_release_tag(tag: &str) -> bool {
     tag.len() == "2020-02-24".len() && tag.starts_with(|c: char| c.is_ascii_digit())
 }
diff --git a/xtask/src/main.rs b/xtask/src/main.rs
index f7a79362d9..81bb3a33f2 100644
--- a/xtask/src/main.rs
+++ b/xtask/src/main.rs
@@ -16,9 +16,8 @@ use xtask::{
     dist::run_dist,
     install::{ClientOpt, InstallCmd, ServerOpt},
     not_bash::pushd,
-    pre_commit, project_root,
-    release::ReleaseCmd,
-    run_clippy, run_fuzzer, run_pre_cache, run_rustfmt, Result,
+    pre_commit, project_root, run_clippy, run_fuzzer, run_pre_cache, run_release, run_rustfmt,
+    Result,
 };
 
 fn main() -> Result<()> {
@@ -103,7 +102,7 @@ FLAGS:
         "release" => {
             let dry_run = args.contains("--dry-run");
             args.finish()?;
-            ReleaseCmd { dry_run }.run()
+            run_release(dry_run)
         }
         "dist" => {
             let nightly = args.contains("--nightly");
diff --git a/xtask/src/release.rs b/xtask/src/release.rs
deleted file mode 100644
index 36c912184f..0000000000
--- a/xtask/src/release.rs
+++ /dev/null
@@ -1,67 +0,0 @@
-use crate::{
-    codegen, is_release_tag,
-    not_bash::{date_iso, fs2, run},
-    project_root, Mode, Result,
-};
-
-pub struct ReleaseCmd {
-    pub dry_run: bool,
-}
-
-impl ReleaseCmd {
-    pub fn run(self) -> Result<()> {
-        if !self.dry_run {
-            run!("git switch release")?;
-            run!("git fetch upstream --tags --force")?;
-            run!("git reset --hard tags/nightly")?;
-            run!("git push")?;
-        }
-        codegen::generate_assists_docs(Mode::Overwrite)?;
-        codegen::generate_feature_docs(Mode::Overwrite)?;
-
-        let website_root = project_root().join("../rust-analyzer.github.io");
-        let changelog_dir = website_root.join("./thisweek/_posts");
-
-        let today = date_iso()?;
-        let commit = run!("git rev-parse HEAD")?;
-        let changelog_n = fs2::read_dir(changelog_dir.as_path())?.count();
-
-        let contents = format!(
-            "\
-    = Changelog #{}
-    :sectanchors:
-    :page-layout: post
-
-    Commit: commit:{}[] +
-    Release: release:{}[]
-
-    == New Features
-
-    * pr:[] .
-
-    == Fixes
-
-    == Internal Improvements
-    ",
-            changelog_n, commit, today
-        );
-
-        let path = changelog_dir.join(format!("{}-changelog-{}.adoc", today, changelog_n));
-        fs2::write(&path, &contents)?;
-
-        for &adoc in ["manual.adoc", "generated_features.adoc", "generated_assists.adoc"].iter() {
-            let src = project_root().join("./docs/user/").join(adoc);
-            let dst = website_root.join(adoc);
-            fs2::copy(src, dst)?;
-        }
-
-        let tags = run!("git tag --list"; echo = false)?;
-        let prev_tag = tags.lines().filter(|line| is_release_tag(line)).last().unwrap();
-
-        let git_log = run!("git log {}..HEAD --merges --reverse", prev_tag; echo = false)?;
-        let git_log_dst = website_root.join("git.log");
-        fs2::write(git_log_dst, &git_log)?;
-
-        Ok(())
-    }
-}


Test patch:
diff --git a/crates/ra_ide/src/snapshots/highlight_doctest.html b/crates/ra_ide/src/snapshots/highlight_doctest.html
new file mode 100644
index 0000000000..2f2d8c900e
--- /dev/null
+++ b/crates/ra_ide/src/snapshots/highlight_doctest.html
@@ -0,0 +1,70 @@
+
+<style>
+body                { margin: 0; }
+pre                 { color: #DCDCCC; background: #3F3F3F; font-size: 22px; padding: 0.4em; }
+
+.lifetime           { color: #DFAF8F; font-style: italic; }
+.comment            { color: #7F9F7F; }
+.struct, .enum      { color: #7CB8BB; }
+.enum_variant       { color: #BDE0F3; }
+.string_literal     { color: #CC9393; }
+.field              { color: #94BFF3; }
+.function           { color: #93E0E3; }
+.operator.unsafe    { color: #E28C14; }
+.parameter          { color: #94BFF3; }
+.text               { color: #DCDCCC; }
+.type               { color: #7CB8BB; }
+.builtin_type       { color: #8CD0D3; }
+.type_param         { color: #DFAF8F; }
+.attribute          { color: #94BFF3; }
+.numeric_literal    { color: #BFEBBF; }
+.bool_literal       { color: #BFE6EB; }
+.macro              { color: #94BFF3; }
+.module             { color: #AFD8AF; }
+.variable           { color: #DCDCCC; }
+.format_specifier   { color: #CC696B; }
+.mutable            { text-decoration: underline; }
+
+.keyword            { color: #F0DFAF; font-weight: bold; }
+.keyword.unsafe     { color: #BC8383; font-weight: bold; }
+.control            { font-style: italic; }
+</style>
+<pre><code><span class="keyword">impl</span> <span class="unresolved_reference">Foo</span> {
+    <span class="comment">/// Constructs a new `Foo`.</span>
+    <span class="comment">///</span>
+    <span class="comment">/// # Examples</span>
+    <span class="comment">///</span>
+    <span class="comment">/// ```</span>
+    <span class="comment">/// #</span> <span class="attribute">#![</span><span class="function attribute">allow</span><span class="attribute">(unused_mut)]</span>
+    <span class="comment">/// </span><span class="keyword">let</span> <span class="keyword">mut</span> <span class="variable declaration mutable">foo</span>: <span class="unresolved_reference">Foo</span> = <span class="unresolved_reference">Foo</span>::<span class="unresolved_reference">new</span>();
+    <span class="comment">/// ```</span>
+    <span class="keyword">pub</span> <span class="keyword">const</span> <span class="keyword">fn</span> <span class="function declaration">new</span>() -&gt; <span class="unresolved_reference">Foo</span> {
+        <span class="unresolved_reference">Foo</span> { }
+    }
+
+    <span class="comment">/// `bar` method on `Foo`.</span>
+    <span class="comment">///</span>
+    <span class="comment">/// # Examples</span>
+    <span class="comment">///</span>
+    <span class="comment">/// ```</span>
+    <span class="comment">/// </span><span class="keyword">let</span> <span class="variable declaration">foo</span> = <span class="unresolved_reference">Foo</span>::<span class="unresolved_reference">new</span>();
+    <span class="comment">///</span>
+    <span class="comment">/// </span><span class="comment">// calls bar on foo</span>
+    <span class="comment">/// </span><span class="macro">assert!</span>(foo.bar());
+    <span class="comment">///</span>
+    <span class="comment">/// </span><span class="comment">/* multi-line
+    </span><span class="comment">/// </span><span class="comment">       comment */</span>
+    <span class="comment">///</span>
+    <span class="comment">/// </span><span class="keyword">let</span> <span class="variable declaration">multi_line_string</span> = <span class="string_literal">"Foo
+    </span><span class="comment">/// </span><span class="string_literal">  bar
+    </span><span class="comment">/// </span><span class="string_literal">         "</span>;
+    <span class="comment">///</span>
+    <span class="comment">/// ```</span>
+    <span class="comment">///</span>
+    <span class="comment">/// ```</span>
+    <span class="comment">/// </span><span class="keyword">let</span> <span class="variable declaration">foobar</span> = <span class="unresolved_reference">Foo</span>::<span class="unresolved_reference">new</span>().<span class="unresolved_reference">bar</span>();
+    <span class="comment">/// ```</span>
+    <span class="keyword">pub</span> <span class="keyword">fn</span> <span class="function declaration">foo</span>(&<span class="self_keyword">self</span>) -&gt; <span class="builtin_type">bool</span> {
+        <span class="bool_literal">true</span>
+    }
+}</code></pre>
\ No newline at end of file
diff --git a/crates/ra_ide/src/syntax_highlighting/tests.rs b/crates/ra_ide/src/syntax_highlighting/tests.rs
index 5e42c5b55b..ba345d90a0 100644
--- a/crates/ra_ide/src/syntax_highlighting/tests.rs
+++ b/crates/ra_ide/src/syntax_highlighting/tests.rs
@@ -284,3 +284,53 @@ fn main() {
         false,
     );
 }
+
+#[test]
+fn test_highlight_doctest() {
+    check_highlighting(
+        r#"
+impl Foo {
+    /// Constructs a new `Foo`.
+    ///
+    /// # Examples
+    ///
+    /// ```
+    /// # #![allow(unused_mut)]
+    /// let mut foo: Foo = Foo::new();
+    /// ```
+    pub const fn new() -> Foo {
+        Foo { }
+    }
+
+    /// `bar` method on `Foo`.
+    ///
+    /// # Examples
+    ///
+    /// ```
+    /// let foo = Foo::new();
+    ///
+    /// // calls bar on foo
+    /// assert!(foo.bar());
+    ///
+    /// /* multi-line
+    ///        comment */
+    ///
+    /// let multi_line_string = "Foo
+    ///   bar
+    ///          ";
+    ///
+    /// ```
+    ///
+    /// ```
+    /// let foobar = Foo::new().bar();
+    /// ```
+    pub fn foo(&self) -> bool {
+        true
+    }
+}
+"#
+        .trim(),
+        "crates/ra_ide/src/snapshots/highlight_doctest.html",
+        false,
+    )
+}