456 lines
19 KiB
C#
456 lines
19 KiB
C#
// 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;
|
||
|
||
/// <summary>
|
||
/// An inline parser for <see cref="EmphasisInline"/>.
|
||
/// </summary>
|
||
/// <seealso cref="InlineParser" />
|
||
/// <seealso cref="IPostInlineProcessor" />
|
||
public class EmphasisInlineParser : InlineParser, IPostInlineProcessor
|
||
{
|
||
private CharacterMap<EmphasisDescriptor>? emphasisMap;
|
||
private readonly DelimitersObjectCache inlinesCache = new();
|
||
/// <summary>
|
||
/// Represents the EmphasisInline type.
|
||
/// </summary>
|
||
public delegate EmphasisInline? TryCreateEmphasisInlineDelegate(char emphasisChar, int delimiterCount);
|
||
|
||
/// <summary>
|
||
/// Initializes a new instance of the <see cref="EmphasisInlineParser"/> class.
|
||
/// </summary>
|
||
public EmphasisInlineParser()
|
||
{
|
||
EmphasisDescriptors =
|
||
[
|
||
new EmphasisDescriptor('*', 1, 2, true),
|
||
new EmphasisDescriptor('_', 1, 2, false)
|
||
];
|
||
}
|
||
|
||
/// <summary>
|
||
/// Gets the emphasis descriptors.
|
||
/// </summary>
|
||
public List<EmphasisDescriptor> EmphasisDescriptors { get; }
|
||
|
||
/// <summary>
|
||
/// Gets or toggles whether the emphasis parser should be CJK-friendly.
|
||
/// </summary>
|
||
/// <seealso href="https://github.com/tats-u/markdown-cjk-friendly"/>
|
||
public bool CjkFriendlyEmphasis { get; set; } = false;
|
||
|
||
/// <summary>
|
||
/// Determines whether this parser is using the specified character as an emphasis delimiter.
|
||
/// </summary>
|
||
/// <param name="c">The character to look for.</param>
|
||
/// <returns><c>true</c> if this parser is using the specified character as an emphasis delimiter; otherwise <c>false</c></returns>
|
||
public bool HasEmphasisChar(char c)
|
||
{
|
||
foreach (var emphasis in EmphasisDescriptors)
|
||
{
|
||
if (emphasis.Character == c)
|
||
{
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Gets or sets the try create emphasis inline list.
|
||
/// </summary>
|
||
public readonly List<TryCreateEmphasisInlineDelegate> TryCreateEmphasisInlineList = [];
|
||
|
||
/// <summary>
|
||
/// Performs the initialize operation.
|
||
/// </summary>
|
||
public override void Initialize()
|
||
{
|
||
OpeningCharacters = new char[EmphasisDescriptors.Count];
|
||
|
||
var tempMap = new List<KeyValuePair<char, EmphasisDescriptor>>();
|
||
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<char, EmphasisDescriptor>(emphasis.Character, emphasis));
|
||
}
|
||
|
||
emphasisMap = new CharacterMap<EmphasisDescriptor>(tempMap);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Performs the post process operation.
|
||
/// </summary>
|
||
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<ContainerInline>(root);
|
||
|
||
List<EmphasisDelimiterInline>? 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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Attempts to match the parser at the current position.
|
||
/// </summary>
|
||
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<Rune>?) 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<EmphasisDelimiterInline> 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;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Any delimiters left must be literal
|
||
for (int i = 0; i < delimiters.Count; i++)
|
||
{
|
||
var delimiter = delimiters[i];
|
||
delimiter.ReplaceBy(delimiter.AsLiteralInline());
|
||
}
|
||
delimiters.Clear();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Represents the DelimitersObjectCache type.
|
||
/// </summary>
|
||
public class DelimitersObjectCache : ObjectCache<List<EmphasisDelimiterInline>>
|
||
{
|
||
/// <summary>
|
||
/// Performs the new instance operation.
|
||
/// </summary>
|
||
protected override List<EmphasisDelimiterInline> NewInstance()
|
||
{
|
||
return new List<EmphasisDelimiterInline>(4);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Performs the reset operation.
|
||
/// </summary>
|
||
protected override void Reset(List<EmphasisDelimiterInline> instance)
|
||
{
|
||
instance.Clear();
|
||
}
|
||
}
|
||
}
|