@@ -585,37 +585,107 @@ internal data class LiveStyleSpan(val range: IntRange, val style: InlineStyle)
585585 */
586586internal fun liveInlineMarkdownStyleRanges (source : String ): List <LiveStyleSpan > {
587587 if (! source.any { it in LIVE_STYLE_DELIMITER_SET }) return emptyList()
588+ val codeMatches = LIVE_CODE .findAll(source).toList()
589+ // Returns true when either delimiter boundary of this match falls inside a code span. Markdown entirely inside
590+ // code, or malformed markdown crossing a code boundary, must not add styling. A code span nested inside outer
591+ // emphasis is still allowed, so outer bold text can retain both its bold span and an inner code span.
592+ val isBlockedByCodeSpan: (MatchResult ) -> Boolean = { match ->
593+ codeMatches.any { codeMatch -> match.range.first in codeMatch.range || match.range.last in codeMatch.range }
594+ }
588595 return buildList {
589- LIVE_BOLD .findAll(source).forEach { add(LiveStyleSpan (it.groups[1 ]!! .range, InlineStyle .Bold )) }
590- LIVE_ITALIC .findAll(source).forEach { add(LiveStyleSpan (it.groups[1 ]!! .range, InlineStyle .Italic )) }
591- LIVE_STRIKE .findAll(source).forEach { add(LiveStyleSpan (it.groups[1 ]!! .range, InlineStyle .Strikethrough )) }
592- LIVE_CODE .findAll(source).forEach { add(LiveStyleSpan (it.groups[1 ]!! .range, InlineStyle .Code )) }
596+ LIVE_BOLD .findAll(source).filterNot(isBlockedByCodeSpan).forEach {
597+ add(LiveStyleSpan (it.groups[1 ]!! .range, InlineStyle .Bold ))
598+ }
599+ LIVE_ITALIC .findAll(source).filterNot(isBlockedByCodeSpan).forEach {
600+ add(LiveStyleSpan (it.groups[1 ]!! .range, InlineStyle .Italic ))
601+ }
602+ LIVE_STRIKE .findAll(source).filterNot(isBlockedByCodeSpan).forEach {
603+ add(LiveStyleSpan (it.groups[1 ]!! .range, InlineStyle .Strikethrough ))
604+ }
605+ codeMatches.forEach { add(LiveStyleSpan (it.groups[1 ]!! .range, InlineStyle .Code )) }
593606 }
607+ .sortedWith(compareBy<LiveStyleSpan >({ it.range.first }, { it.range.last }, { it.style.ordinal }))
594608}
595609
596610private val LIVE_STYLE_DELIMITER_SET = setOf (' *' , ' ~' , ' `' )
597611
612+ internal data class MentionReplacement (val range : IntRange , val text : String )
613+
614+ internal data class MentionOutputPlan (val replacements : List <MentionReplacement >, val styleSpans : List <LiveStyleSpan >)
615+
616+ /* *
617+ * Builds presentation edits from the immutable source buffer. Markdown is parsed before friendly-name replacement so
618+ * display names containing `*`, `~`, or backticks cannot introduce formatting that was not present in the stored text.
619+ */
620+ internal fun mentionOutputPlan (source : String , candidatesById : Map <String , MentionCandidate >): MentionOutputPlan {
621+ val replacements =
622+ MENTION_TOKEN_REGEX .findAll(source)
623+ .mapNotNull { match ->
624+ val candidate = candidatesById[match.groupValues[1 ]] ? : return @mapNotNull null
625+ MentionReplacement (match.range, " @" + candidate.longName.ifEmpty { candidate.shortName })
626+ }
627+ .toList()
628+ val styleSpans = remapStyleSpans(liveInlineMarkdownStyleRanges(source), replacements)
629+ return MentionOutputPlan (replacements, styleSpans)
630+ }
631+
632+ /* * Remaps source-buffer style ranges through non-overlapping mention replacements. */
633+ internal fun remapStyleSpans (spans : List <LiveStyleSpan >, replacements : List <MentionReplacement >): List <LiveStyleSpan > {
634+ if (spans.isEmpty() || replacements.isEmpty()) return spans
635+ val sortedReplacements = replacements.sortedBy { it.range.first }
636+ return spans.mapNotNull { span ->
637+ val start = remapBoundary(span.range.first, sortedReplacements, preferReplacementEnd = false )
638+ val endExclusive = remapBoundary(span.range.last + 1 , sortedReplacements, preferReplacementEnd = true )
639+ if (start >= endExclusive) null else span.copy(range = start until endExclusive)
640+ }
641+ }
642+
643+ private fun remapBoundary (
644+ sourceOffset : Int ,
645+ replacements : List <MentionReplacement >,
646+ preferReplacementEnd : Boolean ,
647+ ): Int {
648+ var delta = 0
649+ var mappedOffset: Int? = null
650+ val iterator = replacements.iterator()
651+ while (iterator.hasNext() && mappedOffset == null ) {
652+ val replacement = iterator.next()
653+ val sourceStart = replacement.range.first
654+ val sourceEndExclusive = replacement.range.last + 1
655+ mappedOffset =
656+ when {
657+ sourceOffset <= sourceStart -> sourceOffset + delta
658+
659+ sourceOffset < sourceEndExclusive ->
660+ sourceStart + delta + if (preferReplacementEnd) replacement.text.length else 0
661+
662+ else -> {
663+ delta + = replacement.text.length - (sourceEndExclusive - sourceStart)
664+ null
665+ }
666+ }
667+ }
668+ return mappedOffset ? : sourceOffset + delta
669+ }
670+
598671/* *
599672 * Displays `@!<hex>` tokens as `@FriendlyName` and applies live inline-markdown styling (bold/italic/strikethrough/
600673 * code) while typing. Both are presentation-only via [OutputTransformation]: the stored buffer keeps the hex wire form
601674 * and the raw markdown delimiters, so the bytes sent are unchanged.
602675 */
603676@OptIn(ExperimentalFoundationApi ::class )
604- private fun mentionOutputTransformation (nodesById : Map <String , MentionCandidate >) = OutputTransformation {
677+ private fun mentionOutputTransformation (candidatesById : Map <String , MentionCandidate >) = OutputTransformation {
605678 val source = toString()
606679 // Fast path: plain text with no mention tokens or markdown delimiters — skip all scanning.
607680 val hasMention = source.indexOf(' @' ) >= 0
608681 val needsStyle = hasMention || source.any { it in LIVE_STYLE_DELIMITER_SET }
609682 if (! needsStyle) return @OutputTransformation
610683
611- for (match in MENTION_TOKEN_REGEX .findAll (source).toList().asReversed()) {
612- val candidate = nodesById[match.groupValues[ 1 ]] ? : continue
613- replace(match .range.first, match .range.last + 1 , " @ " + candidate.longName.ifEmpty { candidate.shortName } )
684+ val plan = mentionOutputPlan (source, candidatesById)
685+ for (replacement in plan.replacements.asReversed()) {
686+ replace(replacement .range.first, replacement .range.last + 1 , replacement.text )
614687 }
615- // Rescan the post-replacement buffer — mention replacements may have changed text length,
616- // so the original source offsets are no longer valid.
617- val postMentions = toString()
618- for (span in liveInlineMarkdownStyleRanges(postMentions)) {
688+ for (span in plan.styleSpans) {
619689 val spanStyle =
620690 when (span.style) {
621691 InlineStyle .Bold -> SpanStyle (fontWeight = FontWeight .Bold )
0 commit comments