// Copyright (c) Alexandre Mutel. All rights reserved. // This file is licensed under the BSD-Clause 2 license. // See the license.txt file in the project root for more information. using System.Diagnostics; using System.Runtime.CompilerServices; using System.Text; using Markdig.Helpers; using Markdig.Renderers.Html; using Markdig.Syntax; using Markdig.Syntax.Inlines; namespace Markdig.Parsers.Inlines; /// /// An inline parser for . /// /// /// public class EmphasisInlineParser : InlineParser, IPostInlineProcessor { private CharacterMap? emphasisMap; private readonly DelimitersObjectCache inlinesCache = new(); /// /// Represents the EmphasisInline type. /// public delegate EmphasisInline? TryCreateEmphasisInlineDelegate(char emphasisChar, int delimiterCount); /// /// Initializes a new instance of the class. /// public EmphasisInlineParser() { EmphasisDescriptors = [ new EmphasisDescriptor('*', 1, 2, true), new EmphasisDescriptor('_', 1, 2, false) ]; } /// /// Gets the emphasis descriptors. /// public List EmphasisDescriptors { get; } /// /// Gets or toggles whether the emphasis parser should be CJK-friendly. /// /// public bool CjkFriendlyEmphasis { get; set; } = false; /// /// Determines whether this parser is using the specified character as an emphasis delimiter. /// /// The character to look for. /// true if this parser is using the specified character as an emphasis delimiter; otherwise false public bool HasEmphasisChar(char c) { foreach (var emphasis in EmphasisDescriptors) { if (emphasis.Character == c) { return true; } } return false; } /// /// Gets or sets the try create emphasis inline list. /// public readonly List TryCreateEmphasisInlineList = []; /// /// Performs the initialize operation. /// public override void Initialize() { OpeningCharacters = new char[EmphasisDescriptors.Count]; var tempMap = new List>(); for (int i = 0; i < EmphasisDescriptors.Count; i++) { var emphasis = EmphasisDescriptors[i]; if (Array.IndexOf(OpeningCharacters, emphasis.Character) >= 0) { ThrowHelper.InvalidOperationException( $"The character `{emphasis.Character}` is already used by another emphasis descriptor"); } OpeningCharacters[i] = emphasis.Character; tempMap.Add(new KeyValuePair(emphasis.Character, emphasis)); } emphasisMap = new CharacterMap(tempMap); } /// /// Performs the post process operation. /// public bool PostProcess(InlineProcessor state, Inline? root, Inline? lastChild, int postInlineProcessorIndex, bool isFinalProcessing) { if (root is null || !root.IsContainerInline) { return true; } ContainerInline container = Unsafe.As(root); List? delimiters = null; if (container is EmphasisDelimiterInline emphasisDelimiter) { delimiters = inlinesCache.Get(); delimiters.Add(emphasisDelimiter); } // Collect all EmphasisDelimiterInline by searching from the root container var child = container.FirstChild; while (child != null) { // Stop the search on the delimitation child if (child == lastChild) { break; } if (child.IsContainer && child is DelimiterInline delimiterInline) { // If we have a delimiter, we search into it as we should have a tree of EmphasisDelimiterInline if (delimiterInline is EmphasisDelimiterInline delimiter) { delimiters ??= inlinesCache.Get(); delimiters.Add(delimiter); } // Follow DelimiterInline (EmphasisDelimiter, TableDelimiter...) // If the delimiter has IsClosed=true (e.g., pipe table delimiter), it has no children // In that case, continue to next sibling instead of stopping var firstChild = delimiterInline.FirstChild; child = firstChild ?? delimiterInline.NextSibling; } else { child = child.NextSibling; } } if (delimiters != null) { ProcessEmphasis(state, delimiters); inlinesCache.Release(delimiters); } return true; } /// /// Attempts to match the parser at the current position. /// public override bool Match(InlineProcessor processor, ref StringSlice slice) { // First, some definitions. // A delimiter run is a sequence of one or more delimiter characters that is not preceded or followed by the same delimiter character // The amount of delimiter characters in the delimiter run may exceed emphasisDesc.MaximumCount, as that is handeled in `ProcessEmphasis` var delimiterChar = slice.CurrentChar; var emphasisDesc = emphasisMap![delimiterChar]!; Rune pc = (Rune)0; Rune twoPreviousChar = default; if (processor.Inline is HtmlEntityInline htmlEntityInline) { if (htmlEntityInline.Transcoded.Length > 0) { var transcoded = htmlEntityInline.Transcoded.Text.AsSpan(htmlEntityInline.Transcoded.Start, htmlEntityInline.Transcoded.Length); Rune.DecodeLastFromUtf16(transcoded, out pc, out int charsConsumed); if (CjkFriendlyEmphasis && charsConsumed < transcoded.Length) { Rune.DecodeLastFromUtf16(transcoded.Slice(0, transcoded.Length - charsConsumed), out twoPreviousChar, out _); } } } if (pc.Value == 0) { pc = slice.PeekRuneExtra(-1); if (CjkFriendlyEmphasis) { // This cannot be a delegate (Func?) because slice is a reference twoPreviousChar = slice.PeekRuneExtra(-1 - pc.Utf16SequenceLength); } // delimiterChar is BMP, so slice.PeekCharExtra(-2) is (a part of) the character two positions back. if (pc == (Rune)delimiterChar && slice.PeekCharExtra(-2) != '\\') { // If we get here, we determined that either: // a) there weren't enough delimiters in the delimiter run to satisfy the MinimumCount condition // b) the previous character couldn't open/close return false; } } var startPosition = slice.Start; int delimiterCount = slice.CountAndSkipChar(delimiterChar); // If the emphasis doesn't have the minimum required character if (delimiterCount < emphasisDesc.MinimumCount) { return false; } Rune c = slice.CurrentRune; // The following character is actually an entity, we need to decode it if (HtmlEntityParser.TryParse(ref slice, out string? htmlString, out int htmlLength)) { // Note: c is U+FFFD when decode error Rune.DecodeFromUtf16(htmlString, out c, out _); } bool canOpen; bool canClose; // Calculate Open-Close for current character if (CjkFriendlyEmphasis) { CharHelper.CheckOpenCloseDelimiterCjkFriendly(pc, c, twoPreviousChar, emphasisDesc.EnableWithinWord, out canOpen, out canClose); } else { CharHelper.CheckOpenCloseDelimiter(pc, c, emphasisDesc.EnableWithinWord, out canOpen, out canClose); } // We have potentially an open or close emphasis if (canOpen || canClose) { var delimiterType = DelimiterType.Undefined; if (canOpen) delimiterType |= DelimiterType.Open; if (canClose) delimiterType |= DelimiterType.Close; var delimiter = new EmphasisDelimiterInline(this, emphasisDesc, new StringSlice(slice.Text, startPosition, slice.Start - 1)) { DelimiterCount = delimiterCount, Type = delimiterType, Span = new SourceSpan(processor.GetSourcePosition(startPosition, out int line, out int column), processor.GetSourcePosition(slice.Start - 1)), Column = column, Line = line, }; processor.Inline = delimiter; return true; } // We don't have an emphasis return false; } private void ProcessEmphasis(InlineProcessor processor, List delimiters) { // The following method is inspired by the "An algorithm for parsing nested emphasis and links" // at the end of the CommonMark specs. // TODO: Benchmark difference between using List and LinkedList here since there could be a few Remove calls // Move current_position forward in the delimiter stack (if needed) until // we find the first potential closer with delimiter * or _. (This will be the potential closer closest to the beginning of the input – the first one in parse order.) for (int i = 0; i < delimiters.Count; i++) { var closeDelimiter = delimiters[i]; // Skip delimiters not supported by this instance EmphasisDescriptor? emphasisDesc = emphasisMap![closeDelimiter.DelimiterChar]; if (emphasisDesc is null) { continue; } if ((closeDelimiter.Type & DelimiterType.Close) != 0) { while (closeDelimiter.DelimiterCount >= emphasisDesc.MinimumCount) { // Now, look back in the stack (staying above stack_bottom and the openers_bottom for this delimiter type) // for the first matching potential opener (“matching” means same delimiter). EmphasisDelimiterInline? openDelimiter = null; int openDelimiterIndex = -1; for (int j = i - 1; j >= 0; j--) { var previousOpenDelimiter = delimiters[j]; var isOddMatch = ((closeDelimiter.Type & DelimiterType.Open) != 0 || (previousOpenDelimiter.Type & DelimiterType.Close) != 0) && previousOpenDelimiter.DelimiterCount != closeDelimiter.DelimiterCount && (previousOpenDelimiter.DelimiterCount + closeDelimiter.DelimiterCount) % 3 == 0 && (previousOpenDelimiter.DelimiterCount % 3 != 0 || closeDelimiter.DelimiterCount % 3 != 0); if (previousOpenDelimiter.DelimiterChar == closeDelimiter.DelimiterChar && (previousOpenDelimiter.Type & DelimiterType.Open) != 0 && previousOpenDelimiter.DelimiterCount >= emphasisDesc.MinimumCount && !isOddMatch) { openDelimiter = previousOpenDelimiter; openDelimiterIndex = j; break; } } if (openDelimiter != null) { process_delims: Debug.Assert(openDelimiter.DelimiterCount >= emphasisDesc.MinimumCount, "Extra emphasis should have been discarded by now"); Debug.Assert(closeDelimiter.DelimiterCount >= emphasisDesc.MinimumCount, "Extra emphasis should have been discarded by now"); int delimiterDelta = Math.Min(Math.Min(openDelimiter.DelimiterCount, closeDelimiter.DelimiterCount), emphasisDesc.MaximumCount); // Insert an emph or strong emph node accordingly, after the text node corresponding to the opener. EmphasisInline? emphasis = null; // Go in backwards order to give priority to newer delegates for (int delegateIndex = TryCreateEmphasisInlineList.Count - 1; delegateIndex >= 0; delegateIndex--) { emphasis = TryCreateEmphasisInlineList[delegateIndex].Invoke(closeDelimiter.DelimiterChar, delimiterDelta); if (emphasis != null) break; } emphasis ??= new EmphasisInline { DelimiterChar = closeDelimiter.DelimiterChar, DelimiterCount = delimiterDelta }; // Update position for emphasis var openDelimitercount = openDelimiter.DelimiterCount; var closeDelimitercount = closeDelimiter.DelimiterCount; emphasis!.Span.Start = openDelimiter.Span.Start + openDelimitercount - delimiterDelta; emphasis.Line = openDelimiter.Line; emphasis.Column = openDelimiter.Column + openDelimitercount - delimiterDelta; emphasis.Span.End = closeDelimiter.Span.End - closeDelimitercount + delimiterDelta; openDelimiter.Span.End -= delimiterDelta; openDelimiter.Content.End -= delimiterDelta; closeDelimiter.Content.Start += delimiterDelta; closeDelimiter.Span.Start += delimiterDelta; closeDelimiter.Column += delimiterDelta; openDelimiter.DelimiterCount -= delimiterDelta; closeDelimiter.DelimiterCount -= delimiterDelta; var embracer = (ContainerInline)openDelimiter; // Copy attributes attached to delimiter to the emphasis var attributes = closeDelimiter.TryGetAttributes(); if (attributes != null) { emphasis.SetAttributes(attributes); } // Embrace all delimiters embracer.EmbraceChildrenBy(emphasis); // Remove any intermediate emphasis for (int k = i - 1; k >= openDelimiterIndex + 1; k--) { var literalDelimiter = delimiters[k]; literalDelimiter.ReplaceBy(literalDelimiter.AsLiteralInline()); delimiters.RemoveAt(k); i--; } if (closeDelimiter.DelimiterCount == 0) { var newParent = openDelimiter.DelimiterCount > 0 ? emphasis : emphasis.Parent!; closeDelimiter.MoveChildrenAfter(newParent); closeDelimiter.Remove(); delimiters.RemoveAt(i); i--; // Remove the open delimiter if it is also empty if (openDelimiter.DelimiterCount == 0) { openDelimiter.MoveChildrenAfter(openDelimiter); openDelimiter.Remove(); delimiters.RemoveAt(openDelimiterIndex); i--; } break; } // The current delimiters are matching if (openDelimiter.DelimiterCount >= emphasisDesc.MinimumCount && closeDelimiter.DelimiterCount >= emphasisDesc.MinimumCount) { goto process_delims; } else if (openDelimiter.DelimiterCount > 0) { // There are still delimiter characters left, there's just not enough of them openDelimiter.ReplaceBy(openDelimiter.AsLiteralInline()); delimiters.RemoveAt(openDelimiterIndex); i--; } else { // Remove the open delimiter if it is also empty var firstChild = openDelimiter.FirstChild!; firstChild.Remove(); openDelimiter.ReplaceBy(firstChild); firstChild.IsClosed = true; closeDelimiter.Remove(); firstChild.InsertAfter(closeDelimiter); delimiters.RemoveAt(openDelimiterIndex); i--; } } else if ((closeDelimiter.Type & DelimiterType.Open) == 0) { closeDelimiter.ReplaceBy(closeDelimiter.AsLiteralInline()); delimiters.RemoveAt(i); i--; break; } else { break; } } // The closer could not be fully consumed and still holds leftover delimiter // characters that cannot match any opener. When the closer was embraced as the // closing side of an emphasis, the inlines that follow the closer belong after // that emphasis, not nested inside it. This is already handled when the opener is // fully consumed, but not when the opener also keeps leftover characters. Move the // trailing content out to the outermost enclosing emphasis so it keeps its original // position; otherwise it is rendered before the closing delimiters, which corrupts // round-tripped output when EnableTrackTrivia() is used (for example a grid table // separator, #743). The closer itself keeps its position, matching the behaviour // for a leftover closer that has no trailing content. if (closeDelimiter.DelimiterCount > 0 && closeDelimiter.Parent is EmphasisInline closerParentEmphasis) { var outermostEmphasis = closerParentEmphasis; while (outermostEmphasis.Parent is EmphasisInline ancestorEmphasis) { outermostEmphasis = ancestorEmphasis; } closeDelimiter.MoveChildrenAfter(outermostEmphasis); } } } // Any delimiters left must be literal for (int i = 0; i < delimiters.Count; i++) { var delimiter = delimiters[i]; delimiter.ReplaceBy(delimiter.AsLiteralInline()); } delimiters.Clear(); } /// /// Represents the DelimitersObjectCache type. /// public class DelimitersObjectCache : ObjectCache> { /// /// Performs the new instance operation. /// protected override List NewInstance() { return new List(4); } /// /// Performs the reset operation. /// protected override void Reset(List instance) { instance.Clear(); } } }