Skip to content

Commit 74ee70f

Browse files
vlad20012rrevenantt
authored andcommitted
LI: inject Rust language to doctests
1 parent 237bb93 commit 74ee70f

25 files changed

+731
-43
lines changed

src/main/kotlin/org/rust/cargo/project/configurable/RsProjectConfigurable.kt

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ class RsProjectConfigurable(
3636
private val showTestToolWindowCheckbox: JBCheckBox = JBCheckBox()
3737
private var showTestToolWindow: Boolean by CheckboxDelegate(showTestToolWindowCheckbox)
3838

39+
private val doctestInjectionCheckbox: JBCheckBox = JBCheckBox()
40+
private var doctestInjectionEnabled: Boolean by CheckboxDelegate(doctestInjectionCheckbox)
41+
3942
private val hintProvider = InlayParameterHintsExtension.forLanguage(RsLanguage)
4043
private val hintCheckboxes: Map<String, JBCheckBox> =
4144
hintProvider.supportedOptions.associate { it.id to JBCheckBox() }
@@ -52,6 +55,7 @@ class RsProjectConfigurable(
5255
Show test results in run tool window when testing session begins
5356
instead of raw console.
5457
""")
58+
row("Inject Rust language to documentation comments:", doctestInjectionCheckbox)
5559
val supportedHintOptions = hintProvider.supportedOptions
5660
if (supportedHintOptions.isNotEmpty()) {
5761
block("Hints") {
@@ -73,6 +77,7 @@ class RsProjectConfigurable(
7377
)
7478
expandMacros = settings.expandMacros
7579
showTestToolWindow = settings.showTestToolWindow
80+
doctestInjectionEnabled = settings.doctestInjectionEnabled
7681

7782
for (option in hintProvider.supportedOptions) {
7883
checkboxForOption(option).isSelected = option.get()
@@ -92,7 +97,8 @@ class RsProjectConfigurable(
9297
toolchain = rustProjectSettings.data.toolchain,
9398
explicitPathToStdlib = rustProjectSettings.data.explicitPathToStdlib,
9499
expandMacros = expandMacros,
95-
showTestToolWindow = showTestToolWindow
100+
showTestToolWindow = showTestToolWindow,
101+
doctestInjectionEnabled = doctestInjectionEnabled
96102
)
97103
}
98104

@@ -103,6 +109,7 @@ class RsProjectConfigurable(
103109
|| data.explicitPathToStdlib != settings.explicitPathToStdlib
104110
|| expandMacros != settings.expandMacros
105111
|| showTestToolWindow != settings.showTestToolWindow
112+
|| doctestInjectionEnabled != settings.doctestInjectionEnabled
106113
}
107114

108115
private fun checkboxForOption(opt: Option): JBCheckBox = hintCheckboxes[opt.id]!!

src/main/kotlin/org/rust/cargo/project/settings/RustProjectSettingsService.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ interface RustProjectSettingsService {
2525
val useOffline: Boolean,
2626
val expandMacros: Boolean,
2727
val showTestToolWindow: Boolean,
28+
val doctestInjectionEnabled: Boolean,
2829
val useSkipChildren: Boolean
2930
)
3031

@@ -44,6 +45,7 @@ interface RustProjectSettingsService {
4445
val useOffline: Boolean get() = data.useOffline
4546
val expandMacros: Boolean get() = data.expandMacros
4647
val showTestToolWindow: Boolean get() = data.showTestToolWindow
48+
val doctestInjectionEnabled: Boolean get() = data.doctestInjectionEnabled
4749
val useSkipChildren: Boolean get() = data.useSkipChildren
4850

4951
/*

src/main/kotlin/org/rust/cargo/project/settings/impl/RustProjectSettingsServiceImpl.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ class RustProjectSettingsServiceImpl(
3333
var useOffline: Boolean = false,
3434
var expandMacros: Boolean = true,
3535
var showTestToolWindow: Boolean = true,
36+
var doctestInjectionEnabled: Boolean = true,
3637
var useSkipChildren: Boolean = false
3738
)
3839

@@ -60,6 +61,7 @@ class RustProjectSettingsServiceImpl(
6061
useOffline = state.useOffline,
6162
expandMacros = state.expandMacros,
6263
showTestToolWindow = state.showTestToolWindow,
64+
doctestInjectionEnabled = state.doctestInjectionEnabled,
6365
useSkipChildren = state.useSkipChildren
6466
)
6567
}
@@ -75,6 +77,7 @@ class RustProjectSettingsServiceImpl(
7577
useOffline = value.useOffline,
7678
expandMacros = value.expandMacros,
7779
showTestToolWindow = value.showTestToolWindow,
80+
doctestInjectionEnabled = value.doctestInjectionEnabled,
7881
useSkipChildren = value.useSkipChildren
7982
)
8083
if (state != newState) {
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/*
2+
* Use of this source code is governed by the MIT license that can be
3+
* found in the LICENSE file.
4+
*/
5+
6+
package org.rust.ide.actions
7+
8+
import com.intellij.codeInsight.editorActions.EnterHandler
9+
import com.intellij.injected.editor.EditorWindow
10+
import com.intellij.openapi.actionSystem.DataContext
11+
import com.intellij.openapi.editor.Caret
12+
import com.intellij.openapi.editor.Editor
13+
import com.intellij.openapi.editor.actionSystem.EditorActionHandler
14+
import com.intellij.openapi.editor.actions.EnterAction
15+
import com.intellij.psi.impl.source.tree.injected.InjectedCaret
16+
import org.rust.ide.injected.RsDoctestLanguageInjector
17+
import org.rust.ide.injected.isDoctestInjection
18+
import org.rust.lang.core.psi.RsFile
19+
20+
/**
21+
* This class is used to handle typing enter inside doctest language injection (see [RsDoctestLanguageInjector]).
22+
* Enter handlers are piped:
23+
* [EnterHandler] -> [RsEnterHandler] -> --------------------> [EnterAction.Handler]
24+
* | | \ -> [EnterHandler] -> / ^ just insert new line [originalHandler]
25+
* | this class ^ (the case of injected psi) [injectionEnterHandler]
26+
* front platform handler (handles indents and other complex stuff)
27+
*/
28+
class RsEnterHandler(private val originalHandler: EditorActionHandler) : EditorActionHandler() {
29+
private val injectionEnterHandler = EnterHandler(object : EditorActionHandler() {
30+
override fun doExecute(editor: Editor, caret: Caret?, dataContext: DataContext) {
31+
originalHandler.execute(editor, caret, dataContext)
32+
}
33+
})
34+
35+
public override fun isEnabledForCaret(editor: Editor, caret: Caret, dataContext: DataContext): Boolean {
36+
return originalHandler.isEnabled(editor, caret, dataContext)
37+
}
38+
39+
override fun doExecute(editor: Editor, caret: Caret?, dataContext: DataContext) {
40+
if (editor is EditorWindow && caret is InjectedCaret &&
41+
(editor.injectedFile as? RsFile)?.isDoctestInjection == true) {
42+
injectionEnterHandler.execute(editor.delegate, caret.delegate, dataContext)
43+
} else {
44+
originalHandler.execute(editor, caret, dataContext)
45+
}
46+
}
47+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/*
2+
* Use of this source code is governed by the MIT license that can be
3+
* found in the LICENSE file.
4+
*/
5+
6+
package org.rust.ide.annotator
7+
8+
import com.intellij.lang.annotation.AnnotationHolder
9+
import com.intellij.lang.annotation.HighlightSeverity
10+
import com.intellij.openapi.editor.colors.EditorColors
11+
import com.intellij.psi.PsiElement
12+
import com.intellij.psi.impl.source.tree.injected.InjectionBackgroundSuppressor
13+
import org.rust.cargo.project.settings.rustSettings
14+
import org.rust.ide.injected.RsDoctestLanguageInjector
15+
import org.rust.ide.injected.findDoctestInjectableRanges
16+
import org.rust.lang.core.psi.RsDocCommentImpl
17+
import org.rust.lang.core.psi.ext.RsElement
18+
import org.rust.lang.core.psi.ext.ancestorStrict
19+
import org.rust.lang.core.psi.ext.containingCargoTarget
20+
21+
/**
22+
* Adds missing background for injections from [RsDoctestLanguageInjector].
23+
* Background is disabled by [InjectionBackgroundSuppressor] marker implemented for [RsDocCommentImpl].
24+
*
25+
* We have to do it this way because we want to highlight fully range inside ```backticks```
26+
* but a real injections is shifted by 1 character and empty lines are skipped.
27+
*/
28+
class RsDoctestAnnotator : RsAnnotatorBase() {
29+
override fun annotateInternal(element: PsiElement, holder: AnnotationHolder) {
30+
if (element !is RsDocCommentImpl) return
31+
if (!element.project.rustSettings.doctestInjectionEnabled) return
32+
// only library targets can have doctests
33+
if (element.ancestorStrict<RsElement>()?.containingCargoTarget?.isLib != true) return
34+
35+
val startOffset = element.startOffset
36+
findDoctestInjectableRanges(element).flatten().forEach {
37+
holder.createAnnotation(HighlightSeverity.INFORMATION, it.shiftRight(startOffset), null)
38+
.textAttributes = EditorColors.INJECTED_LANGUAGE_FRAGMENT
39+
}
40+
}
41+
}
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
/*
2+
* Use of this source code is governed by the MIT license that can be
3+
* found in the LICENSE file.
4+
*/
5+
6+
package org.rust.ide.injected
7+
8+
import com.intellij.injected.editor.VirtualFileWindow
9+
import com.intellij.lang.injection.MultiHostInjector
10+
import com.intellij.lang.injection.MultiHostRegistrar
11+
import com.intellij.openapi.project.Project
12+
import com.intellij.openapi.util.TextRange
13+
import com.intellij.openapi.vfs.VirtualFile
14+
import com.intellij.psi.PsiElement
15+
import com.intellij.psi.tree.IElementType
16+
import com.intellij.util.text.CharArrayUtil
17+
import org.rust.cargo.project.settings.rustSettings
18+
import org.rust.cargo.project.workspace.PackageOrigin
19+
import org.rust.cargo.util.AutoInjectedCrates
20+
import org.rust.lang.RsLanguage
21+
import org.rust.lang.core.psi.RS_DOC_COMMENTS
22+
import org.rust.lang.core.psi.RsDocCommentImpl
23+
import org.rust.lang.core.psi.RsFile
24+
import org.rust.lang.core.psi.ext.*
25+
import org.rust.lang.doc.psi.RsDocKind
26+
import org.rust.openapiext.toPsiFile
27+
import org.rust.stdext.nextOrNull
28+
import java.util.regex.Pattern
29+
30+
// See https://github.com/rust-lang/rust/blob/5182cc1c/src/librustdoc/html/markdown.rs#L646
31+
private val LANG_SPLIT_REGEX = Pattern.compile("[^\\w-]+", Pattern.UNICODE_CHARACTER_CLASS)
32+
private val RUST_LANG_ALIASES = listOf(
33+
"rust",
34+
"allow_fail",
35+
"should_panic",
36+
"no_run",
37+
"test_harness",
38+
// "compile_fail", // don't highlight code that is expected to contain errors
39+
"edition2018",
40+
"edition2015"
41+
)
42+
43+
class RsDoctestLanguageInjector : MultiHostInjector {
44+
private data class CodeRange(val start: Int, val end: Int, val codeStart: Int) {
45+
fun isCodeNotEmpty(): Boolean = codeStart + 1 < end
46+
47+
val indent: Int = codeStart - start
48+
49+
fun offsetIndent(indent: Int): CodeRange? =
50+
if (start + indent < end) CodeRange(start + indent, end, codeStart) else null
51+
}
52+
53+
override fun elementsToInjectIn(): List<Class<out PsiElement>> =
54+
listOf(RsDocCommentImpl::class.java)
55+
56+
override fun getLanguagesToInject(registrar: MultiHostRegistrar, context: PsiElement) {
57+
if (context !is RsDocCommentImpl) return
58+
if (!context.isValidHost || context.elementType !in RS_DOC_COMMENTS) return
59+
if (!context.project.rustSettings.doctestInjectionEnabled) return
60+
61+
val rsElement = context.ancestorStrict<RsElement>() ?: return
62+
val cargoTarget = rsElement.containingCargoTarget ?: return
63+
if (!cargoTarget.isLib) return // only library targets can have doctests
64+
val crateName = cargoTarget.normName
65+
val text = context.text
66+
67+
findDoctestInjectableRanges(text, context.elementType).map { ranges ->
68+
ranges.map {
69+
CodeRange(
70+
it.startOffset,
71+
it.endOffset,
72+
CharArrayUtil.shiftForward(text, it.startOffset, it.endOffset, " \t")
73+
)
74+
}
75+
}.map { ranges ->
76+
val commonIndent = ranges.filter { it.isCodeNotEmpty() }.map { it.indent }.min()
77+
val indentedRanges = if (commonIndent != null) ranges.mapNotNull { it.offsetIndent(commonIndent) } else ranges
78+
79+
indentedRanges.map { (start, end, codeStart) ->
80+
// `cargo doc` has special rules for code lines which start with `#`:
81+
// * `# ` prefix is used to mark lines that should be skipped in rendered documentation.
82+
// * `##` prefix is converted to `#`
83+
// See https://github.com/rust-lang/rust/blob/5182cc1c/src/librustdoc/html/markdown.rs#L114
84+
when {
85+
text.startsWith("##", codeStart) -> TextRange(codeStart + 1, end)
86+
text.startsWith("# ", codeStart) -> TextRange(codeStart + 2, end)
87+
else -> TextRange(start, end)
88+
}
89+
}
90+
}.forEach { ranges ->
91+
val inj = registrar.startInjecting(RsLanguage)
92+
93+
ranges.forEachIndexed { index, range ->
94+
val isFirstIteration = index == 0
95+
val isLastIteration = index == ranges.size - 1
96+
97+
val prefix = if (isFirstIteration) {
98+
buildString {
99+
// Yes, we want to skip the only "std" crate. Not core/alloc/etc, the "std" only
100+
val isStdCrate = crateName == AutoInjectedCrates.STD &&
101+
cargoTarget.pkg.origin == PackageOrigin.STDLIB
102+
if (!isStdCrate) {
103+
append("extern crate ")
104+
append(crateName)
105+
append("; ")
106+
}
107+
append("fn main() {")
108+
}
109+
} else {
110+
null
111+
}
112+
val suffix = if (isLastIteration) "}" else null
113+
114+
inj.addPlace(prefix, suffix, context, range)
115+
}
116+
117+
inj.doneInjecting()
118+
}
119+
}
120+
}
121+
122+
fun findDoctestInjectableRanges(comment: RsDocCommentImpl): Sequence<List<TextRange>> =
123+
findDoctestInjectableRanges(comment.text, comment.elementType)
124+
125+
private fun findDoctestInjectableRanges(text: String, elementType: IElementType): Sequence<List<TextRange>> {
126+
// TODO use markdown parser
127+
val tripleBacktickIndices = text.indicesOf("```").toList()
128+
if (tripleBacktickIndices.size < 2) return emptySequence() // no code blocks in the comment
129+
130+
val infix = RsDocKind.of(elementType).infix
131+
132+
return tripleBacktickIndices.asSequence().chunked(2).mapNotNull { idx ->
133+
// Contains code lines inside backticks including `///` at the start and `\n` at the end.
134+
// It doesn't contain the last line with /// ```
135+
val lines = run {
136+
val codeBlockStart = idx[0] + 3 // skip ```
137+
val codeBlockEnd = idx.getOrNull(1) ?: return@mapNotNull null
138+
generateSequence(codeBlockStart) { text.indexOf("\n", it) + 1 }
139+
.takeWhile { it != 0 && it < codeBlockEnd }
140+
.zipWithNext()
141+
.iterator()
142+
}
143+
144+
// ```rust, should_panic, edition2018
145+
// ^ this text
146+
val lang = lines.nextOrNull()?.let { text.substring(it.first, it.second - 1) } ?: return@mapNotNull null
147+
if (lang.isNotEmpty()) {
148+
val parts = lang.split(LANG_SPLIT_REGEX).filter { it.isNotBlank() }
149+
if (parts.any { it !in RUST_LANG_ALIASES }) return@mapNotNull null
150+
}
151+
152+
// skip doc comment infix (`///`, `//!` or ` * `)
153+
val ranges = lines.asSequence().mapNotNull { (lineStart, lineEnd) ->
154+
val index = text.indexOf(infix, lineStart)
155+
if (index != -1 && index < lineEnd) {
156+
val start = index + infix.length
157+
TextRange(start, lineEnd)
158+
} else {
159+
null
160+
}
161+
}.toList()
162+
163+
if (ranges.isEmpty()) return@mapNotNull null
164+
ranges
165+
}
166+
}
167+
168+
private fun String.indicesOf(s: String): Sequence<Int> =
169+
generateSequence(indexOf(s)) { indexOf(s, it + s.length) }.takeWhile { it != -1 }
170+
171+
fun VirtualFile.isDoctestInjection(project: Project): Boolean {
172+
val virtualFileWindow = this as? VirtualFileWindow ?: return false
173+
val hostFile = virtualFileWindow.delegate.toPsiFile(project) as? RsFile ?: return false
174+
val hostElement = hostFile.findElementAt(virtualFileWindow.documentWindow.injectedToHost(0)) ?: return false
175+
return hostElement.elementType in RS_DOC_COMMENTS
176+
}
177+
178+
val RsFile.isDoctestInjection: Boolean
179+
get() = virtualFile?.isDoctestInjection(project) == true
180+
181+
val RsElement.isDoctestInjection: Boolean
182+
get() = (contextualFile as? RsFile)?.isDoctestInjection == true
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/*
2+
* Use of this source code is governed by the MIT license that can be
3+
* found in the LICENSE file.
4+
*/
5+
6+
package org.rust.ide.injected
7+
8+
import com.intellij.openapi.util.TextRange
9+
import com.intellij.psi.LiteralTextEscaper
10+
import com.intellij.psi.PsiLanguageInjectionHost
11+
12+
/** Same as [com.intellij.psi.LiteralTextEscaper.createSimple], but multi line */
13+
class RsSimpleMultiLineEscaper<T: PsiLanguageInjectionHost>(host: T) : LiteralTextEscaper<T>(host) {
14+
override fun decode(rangeInsideHost: TextRange, outChars: java.lang.StringBuilder): Boolean {
15+
outChars.append(rangeInsideHost.substring(myHost.text))
16+
return true
17+
}
18+
19+
override fun getOffsetInHost(offsetInDecoded: Int, rangeInsideHost: TextRange): Int {
20+
return rangeInsideHost.startOffset + offsetInDecoded
21+
}
22+
23+
override fun isOneLine(): Boolean = false
24+
}

0 commit comments

Comments
 (0)