]> git.lizzy.rs Git - rust.git/blob - crates/ide/src/syntax_highlighting/injection.rs
Better fixture highlight
[rust.git] / crates / ide / src / syntax_highlighting / injection.rs
1 //! Syntax highlighting injections such as highlighting of documentation tests.
2
3 use std::{collections::BTreeMap, convert::TryFrom};
4
5 use hir::Semantics;
6 use ide_db::call_info::ActiveParameter;
7 use itertools::Itertools;
8 use syntax::{ast, AstToken, SyntaxNode, SyntaxToken, TextRange, TextSize};
9
10 use crate::{Analysis, Highlight, HighlightModifier, HighlightTag, HighlightedRange, RootDatabase};
11
12 use super::HighlightedRangeStack;
13
14 pub(super) fn highlight_injection(
15     acc: &mut HighlightedRangeStack,
16     sema: &Semantics<RootDatabase>,
17     literal: ast::String,
18     expanded: SyntaxToken,
19 ) -> Option<()> {
20     let active_parameter = ActiveParameter::at_token(&sema, expanded)?;
21     if !active_parameter.name.starts_with("ra_fixture") {
22         return None;
23     }
24     let value = literal.value()?;
25     let marker_info = MarkerInfo::new(&*value);
26     let (analysis, tmp_file_id) = Analysis::from_single_file(marker_info.cleaned_text.clone());
27
28     if let Some(range) = literal.open_quote_text_range() {
29         acc.add(HighlightedRange {
30             range,
31             highlight: HighlightTag::StringLiteral.into(),
32             binding_hash: None,
33         })
34     }
35
36     for mut h in analysis.highlight(tmp_file_id).unwrap() {
37         let range = marker_info.map_range_up(h.range);
38         if let Some(range) = literal.map_range_up(range) {
39             h.range = range;
40             acc.add(h);
41         }
42     }
43
44     if let Some(range) = literal.close_quote_text_range() {
45         acc.add(HighlightedRange {
46             range,
47             highlight: HighlightTag::StringLiteral.into(),
48             binding_hash: None,
49         })
50     }
51
52     Some(())
53 }
54
55 /// Data to remove `$0` from string and map ranges
56 #[derive(Default, Debug)]
57 struct MarkerInfo {
58     cleaned_text: String,
59     markers: Vec<TextRange>,
60 }
61
62 impl MarkerInfo {
63     fn new(mut text: &str) -> Self {
64         let marker = "$0";
65
66         let mut res = MarkerInfo::default();
67         let mut offset: TextSize = 0.into();
68         while !text.is_empty() {
69             let idx = text.find(marker).unwrap_or(text.len());
70             let (chunk, next) = text.split_at(idx);
71             text = next;
72             res.cleaned_text.push_str(chunk);
73             offset += TextSize::of(chunk);
74
75             if let Some(next) = text.strip_prefix(marker) {
76                 text = next;
77
78                 let marker_len = TextSize::of(marker);
79                 res.markers.push(TextRange::at(offset, marker_len));
80                 offset += marker_len;
81             }
82         }
83         res
84     }
85     fn map_range_up(&self, range: TextRange) -> TextRange {
86         TextRange::new(
87             self.map_offset_up(range.start(), true),
88             self.map_offset_up(range.end(), false),
89         )
90     }
91     fn map_offset_up(&self, mut offset: TextSize, start: bool) -> TextSize {
92         for r in &self.markers {
93             if r.start() < offset || (start && r.start() == offset) {
94                 offset += r.len()
95             }
96         }
97         offset
98     }
99 }
100
101 /// Mapping from extracted documentation code to original code
102 type RangesMap = BTreeMap<TextSize, TextSize>;
103
104 const RUSTDOC_FENCE: &'static str = "```";
105 const RUSTDOC_FENCE_TOKENS: &[&'static str] = &[
106     "",
107     "rust",
108     "should_panic",
109     "ignore",
110     "no_run",
111     "compile_fail",
112     "edition2015",
113     "edition2018",
114     "edition2021",
115 ];
116
117 /// Extracts Rust code from documentation comments as well as a mapping from
118 /// the extracted source code back to the original source ranges.
119 /// Lastly, a vector of new comment highlight ranges (spanning only the
120 /// comment prefix) is returned which is used in the syntax highlighting
121 /// injection to replace the previous (line-spanning) comment ranges.
122 pub(super) fn extract_doc_comments(
123     node: &SyntaxNode,
124 ) -> Option<(String, RangesMap, Vec<HighlightedRange>)> {
125     // wrap the doctest into function body to get correct syntax highlighting
126     let prefix = "fn doctest() {\n";
127     let suffix = "}\n";
128     // Mapping from extracted documentation code to original code
129     let mut range_mapping: RangesMap = BTreeMap::new();
130     let mut line_start = TextSize::try_from(prefix.len()).unwrap();
131     let mut is_codeblock = false;
132     let mut is_doctest = false;
133     // Replace the original, line-spanning comment ranges by new, only comment-prefix
134     // spanning comment ranges.
135     let mut new_comments = Vec::new();
136     let doctest = node
137         .children_with_tokens()
138         .filter_map(|el| el.into_token().and_then(ast::Comment::cast))
139         .filter(|comment| comment.kind().doc.is_some())
140         .filter(|comment| {
141             if let Some(idx) = comment.text().find(RUSTDOC_FENCE) {
142                 is_codeblock = !is_codeblock;
143                 // Check whether code is rust by inspecting fence guards
144                 let guards = &comment.text()[idx + RUSTDOC_FENCE.len()..];
145                 let is_rust =
146                     guards.split(',').all(|sub| RUSTDOC_FENCE_TOKENS.contains(&sub.trim()));
147                 is_doctest = is_codeblock && is_rust;
148                 false
149             } else {
150                 is_doctest
151             }
152         })
153         .map(|comment| {
154             let prefix_len = comment.prefix().len();
155             let line: &str = comment.text().as_str();
156             let range = comment.syntax().text_range();
157
158             // whitespace after comment is ignored
159             let pos = if let Some(ws) = line.chars().nth(prefix_len).filter(|c| c.is_whitespace()) {
160                 prefix_len + ws.len_utf8()
161             } else {
162                 prefix_len
163             };
164
165             // lines marked with `#` should be ignored in output, we skip the `#` char
166             let pos = if let Some(ws) = line.chars().nth(pos).filter(|&c| c == '#') {
167                 pos + ws.len_utf8()
168             } else {
169                 pos
170             };
171
172             range_mapping.insert(line_start, range.start() + TextSize::try_from(pos).unwrap());
173             new_comments.push(HighlightedRange {
174                 range: TextRange::new(
175                     range.start(),
176                     range.start() + TextSize::try_from(pos).unwrap(),
177                 ),
178                 highlight: HighlightTag::Comment | HighlightModifier::Documentation,
179                 binding_hash: None,
180             });
181             line_start += range.len() - TextSize::try_from(pos).unwrap();
182             line_start += TextSize::try_from('\n'.len_utf8()).unwrap();
183
184             line[pos..].to_owned()
185         })
186         .join("\n");
187
188     if doctest.is_empty() {
189         return None;
190     }
191
192     let doctest = format!("{}{}{}", prefix, doctest, suffix);
193     Some((doctest, range_mapping, new_comments))
194 }
195
196 /// Injection of syntax highlighting of doctests.
197 pub(super) fn highlight_doc_comment(
198     text: String,
199     range_mapping: RangesMap,
200     new_comments: Vec<HighlightedRange>,
201     stack: &mut HighlightedRangeStack,
202 ) {
203     let (analysis, tmp_file_id) = Analysis::from_single_file(text);
204
205     stack.push();
206     for mut h in analysis.with_db(|db| super::highlight(db, tmp_file_id, None, true)).unwrap() {
207         // Determine start offset and end offset in case of multi-line ranges
208         let mut start_offset = None;
209         let mut end_offset = None;
210         for (line_start, orig_line_start) in range_mapping.range(..h.range.end()).rev() {
211             // It's possible for orig_line_start - line_start to be negative. Add h.range.start()
212             // here and remove it from the end range after the loop below so that the values are
213             // always non-negative.
214             let offset = h.range.start() + orig_line_start - line_start;
215             if line_start <= &h.range.start() {
216                 start_offset.get_or_insert(offset);
217                 break;
218             } else {
219                 end_offset.get_or_insert(offset);
220             }
221         }
222         if let Some(start_offset) = start_offset {
223             h.range = TextRange::new(
224                 start_offset,
225                 h.range.end() + end_offset.unwrap_or(start_offset) - h.range.start(),
226             );
227
228             h.highlight |= HighlightModifier::Injected;
229             stack.add(h);
230         }
231     }
232
233     // Inject the comment prefix highlight ranges
234     stack.push();
235     for comment in new_comments {
236         stack.add(comment);
237     }
238     stack.pop_and_inject(None);
239     stack.pop_and_inject(Some(Highlight::from(HighlightTag::Dummy) | HighlightModifier::Injected));
240 }