[terminal] IJPL-246212 support OSC8 hyperlinks in TerminalOutputPattern (IJ-CR-216855)

(cherry picked from commit 28e56b5ead293497f195df33eb2de79eeea140e2)

IJ-CR-216855

GitOrigin-RevId: 3005b74c4ea472e2bac0d161818c336afae8b4cf
This commit is contained in:
Sergey Simonchik
2026-08-10 18:43:19 +00:00
committed by intellij-monorepo-bot
parent 1e1824023d
commit a8c0e2d9c3
3 changed files with 214 additions and 5 deletions
@@ -5,6 +5,7 @@ import com.intellij.terminal.tests.reworked.util.TerminalOutputPattern.Companion
import com.jediterm.terminal.TerminalColor
import com.jediterm.terminal.TextStyle
import org.assertj.core.api.Assertions.assertThat
import org.jetbrains.plugins.terminal.session.impl.Osc8Hyperlink
import org.jetbrains.plugins.terminal.session.impl.StyleRange
import org.jetbrains.plugins.terminal.view.TerminalLineIndex
import org.jetbrains.plugins.terminal.view.TerminalOffset
@@ -19,6 +20,7 @@ internal class TerminalOutputPattern(
val text: String,
val styles: List<StyleRange>,
val cursorOffset: Int?,
val osc8Hyperlinks: List<Osc8Hyperlink>,
) {
override fun toString(): String {
return asString()
@@ -31,13 +33,17 @@ internal class TerminalOutputPattern(
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is TerminalOutputPattern) return false
return text == other.text && cursorOffset == other.cursorOffset && mergeAdjacentStyles(styles) == mergeAdjacentStyles(other.styles)
return text == other.text &&
cursorOffset == other.cursorOffset &&
mergeAdjacentStyles(styles) == mergeAdjacentStyles(other.styles) &&
osc8Hyperlinks == other.osc8Hyperlinks
}
override fun hashCode(): Int {
var result = text.hashCode()
result = 31 * result + mergeAdjacentStyles(styles).hashCode()
result = 31 * result + (cursorOffset ?: 0)
result = 31 * result + osc8Hyperlinks.hashCode()
return result
}
@@ -57,11 +63,14 @@ internal class TerminalOutputPattern(
}
private val STYLE_TAG_REGEX = Regex("^s([1-9])$")
private const val LINK_TAG_NAME = "a"
private const val LINK_HREF_ATTR = "href"
/**
* Define the content of the [org.jetbrains.plugins.terminal.view.TerminalOutputModel] using a string with XML markup:
* 1. Use `<cursor>` tag inside string to specify cursor position
* 2. Use `<s1>`, `<s2>`, etc. tags to specify styles, like `<s1>hello</s1>`
* 3. Use `<a href="...">` tags to specify OSC8 hyperlinks, like `<a href="https://example.com">hello</a>`
*/
internal fun outputPattern(pattern: String): TerminalOutputPattern {
// Preprocess: replace <cursor> with <cursor/> so JSoup XML parser treats it as self-closing
@@ -70,9 +79,11 @@ internal fun outputPattern(pattern: String): TerminalOutputPattern {
val textBuilder = StringBuilder()
val styles = mutableListOf<StyleRange>()
val osc8Hyperlinks = mutableListOf<Osc8Hyperlink>()
var cursorOffset: Int? = null
fun processNodes(nodes: List<Node>, currentStyleIndex: Int?) {
// currentTag is the tag we're nested inside, e.g. "s1" or "a"; styles and links may not nest into each other.
fun processNodes(nodes: List<Node>, currentTag: String?) {
for (node in nodes) {
when (node) {
is TextNode -> {
@@ -85,17 +96,41 @@ internal fun outputPattern(pattern: String): TerminalOutputPattern {
require(node.childNodeSize() == 0) { "<cursor> tag must be empty" }
cursorOffset = textBuilder.length
}
else if (tagName == LINK_TAG_NAME) {
require(currentTag == null) { "Nested <$tagName> tags are not allowed: <$tagName> inside <$currentTag>" }
require(node.hasAttr(LINK_HREF_ATTR)) { "<a> tag must have an href attribute" }
val uri = node.attr(LINK_HREF_ATTR)
val startOffset = textBuilder.length
val cursorBefore = cursorOffset
processNodes(node.childNodes(), tagName)
val endOffset = textBuilder.length
require(!textBuilder.substring(startOffset, endOffset).contains('\n')) {
"<a> tags cannot span multiple lines"
}
if (cursorOffset != null && cursorOffset != cursorBefore) {
require(cursorOffset != startOffset && cursorOffset != endOffset) {
"<cursor> must not be placed at the boundary inside <a>; place it outside the tag instead"
}
}
if (startOffset != endOffset) {
osc8Hyperlinks.add(Osc8Hyperlink(startOffset.toLong(), endOffset.toLong(), uri))
}
}
else {
val match = STYLE_TAG_REGEX.matchEntire(tagName)
requireNotNull(match) { "Unknown tag: <$tagName>" }
require(currentStyleIndex == null) { "Nested style tags are not allowed: <$tagName> inside <s${currentStyleIndex!! + 1}>" }
require(currentTag == null) { "Nested style tags are not allowed: <$tagName> inside <$currentTag>" }
val styleNum = match.groupValues[1].toInt()
val styleIndex = styleNum - 1
val startOffset = textBuilder.length
val cursorBefore = cursorOffset
processNodes(node.childNodes(), styleIndex)
processNodes(node.childNodes(), tagName)
val endOffset = textBuilder.length
require(!textBuilder.substring(startOffset, endOffset).contains('\n')) {
@@ -129,6 +164,7 @@ internal fun outputPattern(pattern: String): TerminalOutputPattern {
text = textBuilder.toString(),
styles = styles,
cursorOffset = cursorOffset,
osc8Hyperlinks = osc8Hyperlinks,
)
}
@@ -147,6 +183,10 @@ internal fun TerminalOutputPattern.asString(): String {
insertions.add(Insertion(style.startOffset.toInt(), 2, "<s$tagNum>"))
insertions.add(Insertion(style.endOffset.toInt(), 0, "</s$tagNum>"))
}
for (link in osc8Hyperlinks) {
insertions.add(Insertion(link.startOffset.toInt(), 2, "<a href=\"${link.uri}\">"))
insertions.add(Insertion(link.endOffset.toInt(), 0, "</a>"))
}
if (cursorOffset != null) {
insertions.add(Insertion(cursorOffset, 1, "<cursor>"))
}
@@ -178,7 +218,7 @@ private fun mergeAdjacentStyles(styles: List<StyleRange>): List<StyleRange> {
}
internal fun MutableTerminalOutputModel.updateContent(absoluteLineIndex: Long, pattern: TerminalOutputPattern) {
updateContent(absoluteLineIndex, pattern.text, pattern.styles)
updateContent(absoluteLineIndex, pattern.text, pattern.styles, pattern.osc8Hyperlinks)
if (pattern.cursorOffset != null) {
val lineStartOffset = getStartOfLine(TerminalLineIndex.of(absoluteLineIndex))
updateCursorPosition(lineStartOffset + pattern.cursorOffset.toLong())
@@ -190,6 +230,9 @@ internal fun MutableTerminalOutputModel.replaceContent(
length: Int,
pattern: TerminalOutputPattern,
) {
require(pattern.osc8Hyperlinks.isEmpty()) {
"replaceContent cannot add OSC8 hyperlinks; the model clears links over the replaced range"
}
replaceContent(offset, length, pattern.text, pattern.styles)
}
@@ -201,10 +244,17 @@ internal fun MutableTerminalOutputModel.toPattern(): TerminalOutputPattern {
endOffset = it.endOffset - state.trimmedCharsCount,
)
}
val relativeLinks = state.osc8Hyperlinks.map {
it.copy(
startOffset = it.startOffset - state.trimmedCharsCount,
endOffset = it.endOffset - state.trimmedCharsCount,
)
}
return TerminalOutputPattern(
text = state.text,
styles = relativeStyles,
cursorOffset = state.cursorOffset,
osc8Hyperlinks = relativeLinks,
)
}
@@ -6,6 +6,7 @@ import com.intellij.testFramework.fixtures.BasePlatformTestCase
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatThrownBy
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.JUnit4
@@ -72,6 +73,37 @@ class TerminalOutputPatternExtensionsTest : BasePlatformTestCase() {
assertThat(model.toPattern()).isEqualTo(expected)
}
@Test
fun `model matches text with link`() = runOnEdt {
val model = TerminalTestUtil.createOutputModel()
model.updateContent(0, outputPattern("hello <a href=\"https://example.com\">world</a><cursor>"))
model.assertMatches(outputPattern("hello <a href=\"https://example.com\">world</a><cursor>"))
}
@Test
fun `model does not match different link uri`() = runOnEdt {
val model = TerminalTestUtil.createOutputModel()
model.updateContent(0, outputPattern("<cursor><a href=\"https://a\">hello</a>"))
assertThat(model.matches(outputPattern("<cursor><a href=\"https://b\">hello</a>"))).isFalse()
}
@Test
fun `model toPattern round-trip with link`() = runOnEdt {
val model = TerminalTestUtil.createOutputModel()
val expected = outputPattern("<s1>hi</s1> <a href=\"https://example.com\">there</a><cursor>!")
model.updateContent(0, expected)
assertThat(model.toPattern()).isEqualTo(expected)
}
@Test
fun `replaceContent with a pattern containing a link throws`() = runOnEdt {
val model = TerminalTestUtil.createOutputModel()
model.updateContent(0, outputPattern("hello world"))
assertThatThrownBy {
model.replaceContent(model.startOffset, model.textLength, outputPattern("<a href=\"https://example.com\">goodbye</a>"))
}.isInstanceOf(IllegalArgumentException::class.java)
}
@Test
fun `model matches empty`() = runOnEdt {
val model = TerminalTestUtil.createOutputModel()
@@ -4,6 +4,7 @@ package com.intellij.terminal.tests.reworked.util
import com.jediterm.terminal.TextStyle
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatThrownBy
import org.jetbrains.plugins.terminal.session.impl.Osc8Hyperlink
import org.jetbrains.plugins.terminal.session.impl.StyleRange
import org.junit.Test
@@ -208,6 +209,52 @@ class TerminalOutputPatternTest {
assertThat(pattern.cursorOffset).isEqualTo(3)
}
// Parsing tests: OSC8 links
@Test
fun `parse single link`() {
val pattern = outputPattern("hello <a href=\"https://example.com\">world</a>")
assertThat(pattern.text).isEqualTo("hello world")
assertThat(pattern.osc8Hyperlinks).containsExactly(
osc8Hyperlink(6, 11, "https://example.com")
)
}
@Test
fun `parse empty link tag produces no link`() {
val pattern = outputPattern("<a href=\"https://example.com\"></a>")
assertThat(pattern.text).isEqualTo("")
assertThat(pattern.osc8Hyperlinks).isEmpty()
}
@Test
fun `parse multiple links`() {
val pattern = outputPattern("<a href=\"https://a\">aa</a> <a href=\"https://b\">bb</a>")
assertThat(pattern.text).isEqualTo("aa bb")
assertThat(pattern.osc8Hyperlinks).containsExactly(
osc8Hyperlink(0, 2, "https://a"),
osc8Hyperlink(3, 5, "https://b"),
)
}
@Test
fun `parse link with cursor inside`() {
val pattern = outputPattern("<a href=\"https://example.com\">wor<cursor>ld</a>")
assertThat(pattern.text).isEqualTo("world")
assertThat(pattern.osc8Hyperlinks).containsExactly(
osc8Hyperlink(0, 5, "https://example.com")
)
assertThat(pattern.cursorOffset).isEqualTo(3)
}
@Test
fun `parse link and style side by side`() {
val pattern = outputPattern("<s1>hi</s1> <a href=\"https://example.com\">there</a>")
assertThat(pattern.text).isEqualTo("hi there")
assertThat(pattern.styles).containsExactly(styleRange(0, 2, TerminalOutputPattern.STYLES[0]))
assertThat(pattern.osc8Hyperlinks).containsExactly(osc8Hyperlink(3, 8, "https://example.com"))
}
// Parsing validation tests
@Test
@@ -286,6 +333,48 @@ class TerminalOutputPatternTest {
)
}
@Test
fun `link without href throws`() {
assertThatThrownBy { outputPattern("<a>text</a>") }
.isInstanceOf(IllegalArgumentException::class.java)
.hasMessageContaining("href")
}
@Test
fun `nested link tags throw`() {
assertThatThrownBy { outputPattern("<a href=\"https://a\">hello <a href=\"https://b\">world</a></a>") }
.isInstanceOf(IllegalArgumentException::class.java)
.hasMessageContaining("Nested")
}
@Test
fun `style nested inside link throws`() {
assertThatThrownBy { outputPattern("<a href=\"https://example.com\"><s1>text</s1></a>") }
.isInstanceOf(IllegalArgumentException::class.java)
.hasMessageContaining("Nested")
}
@Test
fun `link nested inside style throws`() {
assertThatThrownBy { outputPattern("<s1><a href=\"https://example.com\">text</a></s1>") }
.isInstanceOf(IllegalArgumentException::class.java)
.hasMessageContaining("Nested")
}
@Test
fun `link spanning multiple lines throws`() {
assertThatThrownBy { outputPattern("<a href=\"https://example.com\">hello\nworld</a>") }
.isInstanceOf(IllegalArgumentException::class.java)
.hasMessageContaining("multiple lines")
}
@Test
fun `cursor at boundary inside link throws`() {
assertThatThrownBy { outputPattern("<a href=\"https://example.com\"><cursor>hello</a>") }
.isInstanceOf(IllegalArgumentException::class.java)
.hasMessageContaining("boundary")
}
// toString round-trip tests
@Test
@@ -353,6 +442,21 @@ class TerminalOutputPatternTest {
assertToStringRoundTrip("<cursor>")
}
@Test
fun `toString with link`() {
assertToStringRoundTrip("hello <a href=\"https://example.com\">world</a>")
}
@Test
fun `toString with link and cursor`() {
assertToStringRoundTrip("<a href=\"https://example.com\">wor<cursor>ld</a>")
}
@Test
fun `toString with link and style side by side`() {
assertToStringRoundTrip("<s1>hi</s1> <a href=\"https://example.com\">there</a>")
}
private fun assertToStringRoundTrip(input: String) {
val pattern = outputPattern(input)
val reconstructed = pattern.toString()
@@ -447,6 +551,29 @@ class TerminalOutputPatternTest {
assertThat(split.hashCode()).isEqualTo(partialMerge.hashCode())
}
@Test
fun `same link pattern is equal`() {
val a = outputPattern("<a href=\"https://example.com\">hello</a>")
val b = outputPattern("<a href=\"https://example.com\">hello</a>")
assertThat(a).isEqualTo(b)
assertThat(a.hashCode()).isEqualTo(b.hashCode())
}
@Test
fun `different link uri not equal`() {
assertThat(outputPattern("<a href=\"https://a\">hello</a>"))
.isNotEqualTo(outputPattern("<a href=\"https://b\">hello</a>"))
}
@Test
fun `link vs no link not equal`() {
assertThat(outputPattern("<a href=\"https://example.com\">hello</a>")).isNotEqualTo(outputPattern("hello"))
}
private fun osc8Hyperlink(startOffset: Long, endOffset: Long, uri: String): Osc8Hyperlink {
return Osc8Hyperlink(startOffset, endOffset, uri)
}
private fun styleRange(
startOffset: Long,
endOffset: Long,