diff --git a/MarkdownSpec.md b/MarkdownSpec.md
index 886e99c95..65fa32eae 100644
--- a/MarkdownSpec.md
+++ b/MarkdownSpec.md
@@ -47,10 +47,10 @@ __Выделенный двумя символами текст__ должен
__Непарные_ символы в рамках одного абзаца не считаются выделением.
-За подчерками, начинающими выделение, должен следовать непробельный символ. Иначе эти_ подчерки_ не считаются выделением
+За подчерками, начинающими выделение, должен следовать непробельный символ. Иначе эти_ подчерки_ не считаются выделением
и остаются просто символами подчерка.
-Подчерки, заканчивающие выделение, должны следовать за непробельным символом. Иначе эти _подчерки _не считаются_ окончанием выделения
+Подчерки, заканчивающие выделение, должны следовать за непробельным символом. Иначе эти _подчерки _не считаются_ окончанием выделения
и остаются просто символами подчерка.
В случае __пересечения _двойных__ и одинарных_ подчерков ни один из них не считается выделением.
@@ -66,8 +66,41 @@ __Непарные_ символы в рамках одного абзаца н
Таким образом
-# Заголовок __с _разными_ символами__
+\# Заголовок \_\_с \_разными_ символами__
превратится в:
-\
Заголовок \с \разными\ символами\\
\ No newline at end of file
+\Заголовок \с \разными\ символами\\
+
+# Ссылки
+
+Текст формата \[название](ссылка) становится HTML тегом название (\название\).
+
+Еще есть упрощенный формат без названия:
+
+Текст в треугольных скобках \<ссылка> превращается в ссылка (\ссылка\).
+
+# Изображения
+
+Текст формата \!\[название](путь до изображения) становится HTML тегом (\).
+
+# Списки
+
+Любое число, начинающееся с новой строки, со следующей за ним точкой, превращает идущий за ним текст до переноса строки в элемент нумерованного списка.
+Несколько таких элементов собираются в список.
+
+Например:
+
+1. Один
+2. Два
+345. Три
+
+Превращается в \\- Один\
\- Два\
\- Три\
\
+
+Аналогично, стоящий в начале строки знак '-' выделяет элемент ненумерованного списка:
+
+- Один
+- Два
+- Три
+
+Превращается в \
diff --git a/cs/Markdown/IStringProcessor.cs b/cs/Markdown/IStringProcessor.cs
new file mode 100644
index 000000000..fdf0a7c5b
--- /dev/null
+++ b/cs/Markdown/IStringProcessor.cs
@@ -0,0 +1,6 @@
+namespace Markdown;
+
+public interface IStringProcessor
+{
+ public string Render(string str);
+}
diff --git a/cs/Markdown/Markdown.csproj b/cs/Markdown/Markdown.csproj
new file mode 100644
index 000000000..fa71b7ae6
--- /dev/null
+++ b/cs/Markdown/Markdown.csproj
@@ -0,0 +1,9 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
diff --git a/cs/Markdown/Md.cs b/cs/Markdown/Md.cs
new file mode 100644
index 000000000..370ae0682
--- /dev/null
+++ b/cs/Markdown/Md.cs
@@ -0,0 +1,222 @@
+using Markdown.MdTagHandlers;
+using Markdown.MdTags;
+using Markdown.MdTags.Interfaces;
+using System.Text;
+
+namespace Markdown;
+
+public class Md : IStringProcessor
+{
+ private readonly ITagPairsFinder tagPairsFinder;
+ private readonly IPairTagsIntersectionHandler pairTagsIntersectionHandler;
+ private readonly IGroupTagInsertionsFinder groupTagInsertionsFinder;
+ private readonly IMdTag[] mdTags;
+
+ public Md() : this(
+ new TagPairsFinder(),
+ new PairTagsIntersectionHandler(),
+ new GroupTagInsertionsFinder(),
+ [
+ new UnderscoresBetweenNumbersIgnoreTag(),
+ new EscapeMdTag(),
+ new OrderedListItemMdTag(),
+ new UnorderedListItemMdTag(),
+ new HeaderMdTag(),
+ new ImageMdTag(),
+ new LinkMdTag(),
+ new BoldTextMdTag(),
+ new ItalicTextMdTag(),
+ ])
+ {
+ }
+
+ internal Md(
+ ITagPairsFinder tagPairsFinder,
+ IPairTagsIntersectionHandler pairTagsIntersectionHandler,
+ IGroupTagInsertionsFinder groupTagInsertionsFinder,
+ params IMdTag[] tags)
+ {
+ ArgumentNullException.ThrowIfNull(tagPairsFinder);
+ ArgumentNullException.ThrowIfNull(pairTagsIntersectionHandler);
+ ArgumentNullException.ThrowIfNull(groupTagInsertionsFinder);
+
+ this.tagPairsFinder = tagPairsFinder;
+ this.pairTagsIntersectionHandler = pairTagsIntersectionHandler;
+ this.groupTagInsertionsFinder = groupTagInsertionsFinder;
+
+ tags ??= [];
+
+ foreach (var tag in tags)
+ {
+ ArgumentNullException.ThrowIfNull(tag);
+
+ if (!IsTagWithSupportedType(tag))
+ {
+ throw new ArgumentException($"Tag '{tag.GetType()}' is unsupported.");
+ }
+ }
+
+ mdTags = tags;
+ }
+
+ public string Render(string mdString)
+ {
+ ArgumentNullException.ThrowIfNull(mdString);
+
+ var tagsIndices = GetTagsIndices(mdString, mdTags)
+ .GroupBy(kv => kv.Key.TagType)
+ .ToDictionary(group => group.Key, group => group.ToDictionary());
+ var htmlReplacements = new Dictionary();
+
+ if (tagsIndices.TryGetValue(TagType.Normal, out var normalTagsIndices))
+ {
+ AddReplacementsRange(htmlReplacements, GetNormalTagsReplacements(mdString, normalTagsIndices));
+ }
+
+ if (tagsIndices.TryGetValue(TagType.Pair, out var pairTagsIndices))
+ {
+ AddReplacementsRange(htmlReplacements, GetPairTagsReplacements(mdString, pairTagsIndices));
+ }
+
+ if (tagsIndices.TryGetValue(TagType.FullLine, out var fullLineTagsIndices))
+ {
+ AddReplacementsRange(htmlReplacements, GetFullLineTagsReplacements(mdString, fullLineTagsIndices));
+ }
+
+ return GetHtmlString(mdString, htmlReplacements);
+ }
+
+ private bool IsTagWithSupportedType(IMdTag tag)
+ {
+ return tag.TagType switch
+ {
+ TagType.Normal => tag is IHtmlConverter and not IGroupedMdTag,
+ TagType.Pair => tag is IHtmlTagsPair,
+ TagType.FullLine => tag is IHtmlTagsPair,
+ TagType.Ignore => true,
+ _ => false,
+ };
+ }
+
+ private Dictionary> GetTagsIndices(string mdString, IEnumerable tags)
+ {
+ var tagsIndices = tags.ToDictionary(tag => tag, _ => new List());
+
+ for (var i = 0; i < mdString.Length; i++)
+ {
+ foreach (var tag in tags)
+ {
+ if (tag.IsMdTag(mdString, i, out var tagLength))
+ {
+ if (tag.TagType != TagType.Ignore)
+ {
+ tagsIndices[tag].Add(new SubstringReplacement(i, tagLength));
+ }
+
+ i += tagLength - 1;
+ break;
+ }
+ }
+ }
+
+ return tagsIndices;
+ }
+
+ private void AddReplacementsRange(
+ Dictionary replacements,
+ IEnumerable replacementsToAdd)
+ {
+ foreach (var newReplacement in replacementsToAdd)
+ {
+ if (replacements.TryGetValue(newReplacement.Index, out var replacement))
+ {
+ var replacementsConcat = replacement.Replacement!.Contains('/')
+ ? replacement.Replacement + newReplacement.Replacement
+ : newReplacement.Replacement + replacement.Replacement;
+ replacement = replacement with { Replacement = replacementsConcat };
+ }
+ else
+ {
+ replacement = newReplacement;
+ }
+
+ replacements[newReplacement.Index] = replacement;
+ }
+ }
+
+ private IEnumerable GetNormalTagsReplacements(
+ string mdString,
+ Dictionary> normalTagsPositions)
+ {
+ return normalTagsPositions
+ .SelectMany(kv => kv.Value
+ .Select(x => x with { Replacement = ((IHtmlConverter)kv.Key).GetHtmlString(mdString, x.Index) }));
+ }
+
+ private IEnumerable GetPairTagsReplacements(
+ string mdString,
+ Dictionary> pairTagsPositions)
+ {
+ var pairedTags = pairTagsPositions
+ .ToDictionary(kv => kv.Key, kv => tagPairsFinder.PairTags(mdString, kv.Value).ToList());
+ var tagPairs = pairTagsIntersectionHandler
+ .RemoveIntersections(pairedTags)
+ .ToDictionary(kv => (IHtmlTagsPair)kv.Key, kv => kv.Value.AsEnumerable());
+
+ return GetPairedTagsReplacements(mdString, tagPairs);
+ }
+
+ private IEnumerable GetFullLineTagsReplacements(
+ string mdString,
+ Dictionary> fullLineTagsPositions)
+ {
+ var pairedTags = fullLineTagsPositions
+ .ToDictionary(kv => (IHtmlTagsPair)kv.Key, kv => tagPairsFinder.PairFullLineTags(mdString, kv.Value));
+
+ return GetPairedTagsReplacements(mdString, pairedTags);
+ }
+
+ private IEnumerable GetPairedTagsReplacements(
+ string mdString,
+ Dictionary> pairedTags)
+ {
+ var htmlReplacements = pairedTags
+ .SelectMany(kv => kv.Value
+ .SelectMany(pair => pair.GetHtmlReplacements(kv.Key).Flatten()));
+ var groupTagsHtmlInsertions = pairedTags
+ .Where(kv => kv.Key is IGroupedMdTag)
+ .SelectMany(kv => groupTagInsertionsFinder
+ .GetGroupTagReplacements(mdString, ((IGroupedMdTag)kv.Key).GroupHtmlTagsPair, kv.Value));
+
+ return htmlReplacements
+ .Concat(groupTagsHtmlInsertions);
+ }
+
+ private string GetHtmlString(string mdString, Dictionary htmlReplacements)
+ {
+ var sb = new StringBuilder();
+
+ for (var i = 0; i < mdString.Length; i++)
+ {
+ var hasReplacement = htmlReplacements.TryGetValue(i, out var replacement);
+
+ if (hasReplacement)
+ {
+ sb.Append(replacement.Replacement);
+ i += Math.Max(0, replacement.Length - 1);
+ }
+
+ if (!hasReplacement || hasReplacement && replacement.Length == 0)
+ {
+ sb.Append(mdString[i]);
+ }
+ }
+
+ if (htmlReplacements.TryGetValue(mdString.Length, out var lastReplacement))
+ {
+ sb.Append(lastReplacement.Replacement);
+ }
+
+ return sb.ToString();
+ }
+}
diff --git a/cs/Markdown/MdTagHandlers/GroupTagInsertionsFinder.cs b/cs/Markdown/MdTagHandlers/GroupTagInsertionsFinder.cs
new file mode 100644
index 000000000..3d0def94e
--- /dev/null
+++ b/cs/Markdown/MdTagHandlers/GroupTagInsertionsFinder.cs
@@ -0,0 +1,39 @@
+using Markdown.MdTags.Interfaces;
+
+namespace Markdown.MdTagHandlers;
+
+internal class GroupTagInsertionsFinder : IGroupTagInsertionsFinder
+{
+ public IEnumerable GetGroupTagReplacements(
+ string mdString,
+ IHtmlTagsPair groupTag,
+ IEnumerable tagPairs)
+ {
+ using var enumerator = tagPairs.GetEnumerator();
+
+ if (!enumerator.MoveNext())
+ {
+ yield break;
+ }
+
+ var previous = enumerator.Current;
+ yield return new SubstringReplacement(previous.OpenTag.Index, 0, groupTag.HtmlOpenTag);
+
+ while (enumerator.MoveNext())
+ {
+ var previousEndIndex = previous.CloseTag.EndIndex;
+ var currentStartIndex = enumerator.Current.OpenTag.Index;
+ var lengthBetweenTags = currentStartIndex - previousEndIndex;
+
+ if (!TagSearchHelper.IsWhiteSpaceSubstring(mdString, previousEndIndex, lengthBetweenTags))
+ {
+ yield return new SubstringReplacement(previousEndIndex, 0, groupTag.HtmlCloseTag);
+ yield return new SubstringReplacement(currentStartIndex - 1, 0, groupTag.HtmlOpenTag);
+ }
+
+ previous = enumerator.Current;
+ }
+
+ yield return new SubstringReplacement(previous.CloseTag.EndIndex, 0, groupTag.HtmlCloseTag);
+ }
+}
diff --git a/cs/Markdown/MdTagHandlers/IGroupTagInsertionsFinder.cs b/cs/Markdown/MdTagHandlers/IGroupTagInsertionsFinder.cs
new file mode 100644
index 000000000..cc6dc9593
--- /dev/null
+++ b/cs/Markdown/MdTagHandlers/IGroupTagInsertionsFinder.cs
@@ -0,0 +1,9 @@
+using Markdown.MdTags.Interfaces;
+
+namespace Markdown.MdTagHandlers;
+
+internal interface IGroupTagInsertionsFinder
+{
+ public IEnumerable GetGroupTagReplacements(
+ string mdString, IHtmlTagsPair groupTag, IEnumerable tagPairs);
+}
diff --git a/cs/Markdown/MdTagHandlers/IPairTagsIntersectionHandler.cs b/cs/Markdown/MdTagHandlers/IPairTagsIntersectionHandler.cs
new file mode 100644
index 000000000..e0ef76d32
--- /dev/null
+++ b/cs/Markdown/MdTagHandlers/IPairTagsIntersectionHandler.cs
@@ -0,0 +1,9 @@
+using Markdown.MdTags.Interfaces;
+
+namespace Markdown.MdTagHandlers;
+
+internal interface IPairTagsIntersectionHandler
+{
+ public Dictionary> RemoveIntersections(
+ Dictionary> tagsIndices);
+}
diff --git a/cs/Markdown/MdTagHandlers/ITagPairsFinder.cs b/cs/Markdown/MdTagHandlers/ITagPairsFinder.cs
new file mode 100644
index 000000000..10ae3e9a5
--- /dev/null
+++ b/cs/Markdown/MdTagHandlers/ITagPairsFinder.cs
@@ -0,0 +1,8 @@
+namespace Markdown.MdTagHandlers;
+
+internal interface ITagPairsFinder
+{
+ public IEnumerable PairTags(string str, IEnumerable tagPositions);
+
+ public IEnumerable PairFullLineTags(string str, IEnumerable tagPositions);
+}
diff --git a/cs/Markdown/MdTagHandlers/PairTagsIntersectionHandler.cs b/cs/Markdown/MdTagHandlers/PairTagsIntersectionHandler.cs
new file mode 100644
index 000000000..d423a44e6
--- /dev/null
+++ b/cs/Markdown/MdTagHandlers/PairTagsIntersectionHandler.cs
@@ -0,0 +1,81 @@
+
+using Markdown.MdTags;
+using Markdown.MdTags.Interfaces;
+
+namespace Markdown.MdTagHandlers;
+
+internal class PairTagsIntersectionHandler : IPairTagsIntersectionHandler
+{
+ public Dictionary> RemoveIntersections(
+ Dictionary> tagsPairs)
+ {
+ var allTagsPairs = Flatten(tagsPairs);
+ var result = tagsPairs.ToDictionary(kv => kv.Key, kv => new List());
+ var previousTag = default(IMdTag);
+ var previousTagReplacementPair = default(TagReplacementsPair);
+
+ foreach (var (tag, tagReplacementPair) in Flatten(tagsPairs))
+ {
+ if (previousTag == null)
+ {
+ result[tag].Add(tagReplacementPair);
+ }
+ else if (HavePartialIntersection(previousTagReplacementPair, tagReplacementPair))
+ {
+ result[previousTag].RemoveAt(result[previousTag].Count - 1);
+ }
+ else if (!IsBoldInsideItalic(previousTag, previousTagReplacementPair, tag, tagReplacementPair))
+ {
+ result[tag].Add(tagReplacementPair);
+ }
+
+ previousTag = tag;
+ previousTagReplacementPair = tagReplacementPair;
+ }
+
+ return result;
+ }
+
+ private (IMdTag Tag, TagReplacementsPair TagReplacementsPair)[] Flatten(
+ Dictionary> tagsPairs)
+ {
+ var indices = tagsPairs.ToDictionary(kv => kv.Key, kv => 0);
+ var totalCount = tagsPairs.Sum(kv => kv.Value.Count);
+ var result = new (IMdTag, TagReplacementsPair)[totalCount];
+
+ for (var i = 0; i < totalCount; i++)
+ {
+ var leftmostTag = tagsPairs.Keys
+ .Where(tag => indices[tag] < tagsPairs[tag].Count)
+ .MinBy(tag => tagsPairs[tag][indices[tag]].OpenTag.Index)!;
+
+ result[i] = (leftmostTag, tagsPairs[leftmostTag][indices[leftmostTag]]);
+ indices[leftmostTag]++;
+ }
+
+ return result;
+ }
+
+ private bool IsBoldInsideItalic(
+ IMdTag previousTag,
+ TagReplacementsPair previousTagReplacementPair,
+ IMdTag tag,
+ TagReplacementsPair tagReplacementPair)
+ {
+ return HaveFullIntersection(previousTagReplacementPair, tagReplacementPair)
+ && previousTag is ItalicTextMdTag
+ && tag is BoldTextMdTag;
+ }
+
+ private bool HavePartialIntersection(TagReplacementsPair left, TagReplacementsPair right)
+ {
+ return left.CloseTag.EndIndex >= right.OpenTag.Index
+ && left.CloseTag.EndIndex < right.CloseTag.EndIndex;
+ }
+
+ private bool HaveFullIntersection(TagReplacementsPair left, TagReplacementsPair right)
+ {
+ return left.CloseTag.EndIndex >= right.OpenTag.Index
+ && left.CloseTag.EndIndex >= right.CloseTag.EndIndex;
+ }
+}
diff --git a/cs/Markdown/MdTagHandlers/TagPairsFinder.cs b/cs/Markdown/MdTagHandlers/TagPairsFinder.cs
new file mode 100644
index 000000000..6a81e037c
--- /dev/null
+++ b/cs/Markdown/MdTagHandlers/TagPairsFinder.cs
@@ -0,0 +1,80 @@
+namespace Markdown.MdTagHandlers;
+
+internal class TagPairsFinder : ITagPairsFinder
+{
+ public IEnumerable PairTags(
+ string str,
+ IEnumerable tagPositions)
+ {
+ var open = default(SubstringReplacement);
+
+ foreach (var current in tagPositions)
+ {
+ var canBeOpenTag = current.EndIndex != str.Length && !char.IsWhiteSpace(str[current.EndIndex]);
+ var canBeCloseTag = current.Index != 0 && !char.IsWhiteSpace(str[current.Index - 1]);
+
+ if (open == default)
+ {
+ if (canBeOpenTag)
+ {
+ open = current;
+ }
+ }
+ else if (canBeCloseTag
+ && current.Index != open.EndIndex
+ && (!canBeOpenTag || AreInSameWord(str, open, current)))
+ {
+ yield return new TagReplacementsPair(open, current);
+ open = default;
+ }
+ else if (canBeOpenTag)
+ {
+ open = current;
+ }
+ }
+ }
+
+ public IEnumerable PairFullLineTags(
+ string str,
+ IEnumerable tagPositions)
+ {
+ return PairFullLineTags(GetLineBreakIndices(str), tagPositions.ToArray());
+ }
+
+ private bool AreInSameWord(string str, SubstringReplacement open, SubstringReplacement close)
+ {
+ for (var i = open.EndIndex; i < close.Index; i++)
+ {
+ if (char.IsWhiteSpace(str[i]))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private IEnumerable PairFullLineTags(
+ int[] lineBreakIndices,
+ SubstringReplacement[] tagPositions)
+ {
+ for (var (i, j) = (0, 0); i < tagPositions.Length; i++)
+ {
+ while (lineBreakIndices[j] < tagPositions[i].EndIndex)
+ {
+ j++;
+ }
+
+ yield return new TagReplacementsPair(tagPositions[i], new SubstringReplacement(lineBreakIndices[j], 0));
+ }
+ }
+
+ private int[] GetLineBreakIndices(string str)
+ {
+ return Enumerable
+ .Range(0, str.Length)
+ .Where(i => str[i] == '\n')
+ .Append(str.Length)
+ .ToArray();
+ }
+}
diff --git a/cs/Markdown/MdTags/BoldTextMdTag.cs b/cs/Markdown/MdTags/BoldTextMdTag.cs
new file mode 100644
index 000000000..3bfc66561
--- /dev/null
+++ b/cs/Markdown/MdTags/BoldTextMdTag.cs
@@ -0,0 +1,20 @@
+using Markdown.MdTags.Interfaces;
+
+namespace Markdown.MdTags;
+
+internal class BoldTextMdTag : IMdTag, IHtmlTagsPair
+{
+ private const string MdTag = "__";
+
+ public string HtmlOpenTag => "";
+
+ public string HtmlCloseTag => "";
+
+ public TagType TagType => TagType.Pair;
+
+ public bool IsMdTag(string mdString, int startIndex, out int tagLength)
+ {
+ tagLength = MdTag.Length;
+ return TagSearchHelper.IsSubstring(mdString, MdTag, startIndex);
+ }
+}
diff --git a/cs/Markdown/MdTags/EscapeMdTag.cs b/cs/Markdown/MdTags/EscapeMdTag.cs
new file mode 100644
index 000000000..f71df7114
--- /dev/null
+++ b/cs/Markdown/MdTags/EscapeMdTag.cs
@@ -0,0 +1,26 @@
+using Markdown.MdTags.Interfaces;
+
+namespace Markdown.MdTags;
+
+internal class EscapeMdTag : IMdTag, IHtmlConverter
+{
+ private const string MdTag = @"\";
+ private const string EscapeCharacters = @"\#-_![<";
+
+ public TagType TagType => TagType.Normal;
+
+ public bool IsMdTag(string mdString, int startIndex, out int tagLength)
+ {
+ var escapeCharIndex = startIndex + MdTag.Length;
+ tagLength = MdTag.Length + 1;
+ return TagSearchHelper.IsSubstring(mdString, MdTag, startIndex)
+ && mdString.Length > escapeCharIndex
+ && EscapeCharacters.Contains(mdString[escapeCharIndex]);
+ }
+
+ public string GetHtmlString(string mdString, int startIndex)
+ {
+ var escapeCharIndex = startIndex + MdTag.Length;
+ return mdString[escapeCharIndex].ToString();
+ }
+}
diff --git a/cs/Markdown/MdTags/HeaderMdTag.cs b/cs/Markdown/MdTags/HeaderMdTag.cs
new file mode 100644
index 000000000..a5e511e47
--- /dev/null
+++ b/cs/Markdown/MdTags/HeaderMdTag.cs
@@ -0,0 +1,22 @@
+using Markdown.MdTags.Interfaces;
+
+namespace Markdown.MdTags;
+
+internal class HeaderMdTag : IMdTag, IHtmlTagsPair
+{
+ private const string MdTag = "#";
+
+ public string HtmlOpenTag => "";
+
+ public string HtmlCloseTag => "
";
+
+ public TagType TagType => TagType.FullLine;
+
+ public bool IsMdTag(string mdString, int startIndex, out int tagLength)
+ {
+ tagLength = MdTag.Length + 1;
+ return TagSearchHelper.IsNewLineStart(mdString, startIndex)
+ && TagSearchHelper.IsSubstring(mdString, MdTag, startIndex)
+ && TagSearchHelper.IsWhitespaceOrStringEnd(mdString, startIndex + MdTag.Length);
+ }
+}
diff --git a/cs/Markdown/MdTags/ImageMdTag.cs b/cs/Markdown/MdTags/ImageMdTag.cs
new file mode 100644
index 000000000..724675237
--- /dev/null
+++ b/cs/Markdown/MdTags/ImageMdTag.cs
@@ -0,0 +1,47 @@
+using Markdown.MdTags.Interfaces;
+
+namespace Markdown.MdTags;
+
+internal class ImageMdTag : IMdTag, IHtmlConverter
+{
+ private const string MdTag = "!";
+
+ private readonly LinkMdTag linkMdTag = new();
+
+ public TagType TagType => TagType.Normal;
+
+ public bool IsMdTag(string mdString, int startIndex, out int tagLength)
+ {
+ if (IsImageTag(mdString, startIndex, out var titleLength, out var pathLength))
+ {
+ tagLength = MdTag.Length + titleLength + pathLength;
+ return true;
+ }
+
+ tagLength = default;
+ return false;
+ }
+
+ public string GetHtmlString(string mdString, int startIndex)
+ {
+ if (IsImageTag(mdString, startIndex, out var titleLegnth, out var pathLength))
+ {
+ var titleStartIndex = startIndex + MdTag.Length;
+ var titleEndIndex = titleStartIndex + titleLegnth;
+ var pathEndIndex = titleEndIndex + pathLength;
+ var title = mdString[(titleStartIndex + 1)..(titleEndIndex - 1)];
+ var path = mdString[(titleEndIndex + 1)..(pathEndIndex - 1)];
+ return $"";
+ }
+
+ throw new InvalidOperationException($"There is no image tag at {startIndex} in '{mdString}'.");
+ }
+
+ private bool IsImageTag(string mdString, int startIndex, out int titleLength, out int pathLength)
+ {
+ titleLength = default;
+ pathLength = default;
+ return TagSearchHelper.IsSubstring(mdString, MdTag, startIndex)
+ && linkMdTag.IsLinkTagWithTitle(mdString, startIndex + MdTag.Length, out titleLength, out pathLength);
+ }
+}
diff --git a/cs/Markdown/MdTags/Interfaces/IGroupedMdTag.cs b/cs/Markdown/MdTags/Interfaces/IGroupedMdTag.cs
new file mode 100644
index 000000000..044f963da
--- /dev/null
+++ b/cs/Markdown/MdTags/Interfaces/IGroupedMdTag.cs
@@ -0,0 +1,9 @@
+namespace Markdown.MdTags.Interfaces;
+
+internal interface IGroupedMdTag : IMdTag
+{
+ ///
+ /// Тег-обертка для группы тегов.
+ ///
+ public IHtmlTagsPair GroupHtmlTagsPair { get; }
+}
diff --git a/cs/Markdown/MdTags/Interfaces/IHtmlConverter.cs b/cs/Markdown/MdTags/Interfaces/IHtmlConverter.cs
new file mode 100644
index 000000000..9cfbf0aee
--- /dev/null
+++ b/cs/Markdown/MdTags/Interfaces/IHtmlConverter.cs
@@ -0,0 +1,6 @@
+namespace Markdown.MdTags.Interfaces;
+
+internal interface IHtmlConverter
+{
+ public string GetHtmlString(string mdString, int startIndex);
+}
diff --git a/cs/Markdown/MdTags/Interfaces/IHtmlTagsPair.cs b/cs/Markdown/MdTags/Interfaces/IHtmlTagsPair.cs
new file mode 100644
index 000000000..01a45bc35
--- /dev/null
+++ b/cs/Markdown/MdTags/Interfaces/IHtmlTagsPair.cs
@@ -0,0 +1,8 @@
+namespace Markdown.MdTags.Interfaces;
+
+internal interface IHtmlTagsPair
+{
+ public string HtmlOpenTag { get; }
+
+ public string HtmlCloseTag { get; }
+}
diff --git a/cs/Markdown/MdTags/Interfaces/IMdTag.cs b/cs/Markdown/MdTags/Interfaces/IMdTag.cs
new file mode 100644
index 000000000..97f0d508f
--- /dev/null
+++ b/cs/Markdown/MdTags/Interfaces/IMdTag.cs
@@ -0,0 +1,8 @@
+namespace Markdown.MdTags.Interfaces;
+
+internal interface IMdTag
+{
+ public TagType TagType { get; }
+
+ public bool IsMdTag(string mdString, int startIndex, out int tagLength);
+}
diff --git a/cs/Markdown/MdTags/ItalicTextMdTag.cs b/cs/Markdown/MdTags/ItalicTextMdTag.cs
new file mode 100644
index 000000000..5affe4abe
--- /dev/null
+++ b/cs/Markdown/MdTags/ItalicTextMdTag.cs
@@ -0,0 +1,20 @@
+using Markdown.MdTags.Interfaces;
+
+namespace Markdown.MdTags;
+
+internal class ItalicTextMdTag : IMdTag, IHtmlTagsPair
+{
+ private const string MdTag = "_";
+
+ public string HtmlOpenTag => "";
+
+ public string HtmlCloseTag => "";
+
+ public TagType TagType => TagType.Pair;
+
+ public bool IsMdTag(string mdString, int startIndex, out int tagLength)
+ {
+ tagLength = MdTag.Length;
+ return TagSearchHelper.IsSubstring(mdString, MdTag, startIndex);
+ }
+}
diff --git a/cs/Markdown/MdTags/LinkMdTag.cs b/cs/Markdown/MdTags/LinkMdTag.cs
new file mode 100644
index 000000000..f15075a6c
--- /dev/null
+++ b/cs/Markdown/MdTags/LinkMdTag.cs
@@ -0,0 +1,57 @@
+using Markdown.MdTags.Interfaces;
+
+namespace Markdown.MdTags;
+
+internal class LinkMdTag : IMdTag, IHtmlConverter
+{
+ public TagType TagType => TagType.Normal;
+
+ public bool IsMdTag(string mdString, int startIndex, out int tagLength)
+ {
+ if (IsAngleBracketsLinkTag(mdString, startIndex, out tagLength))
+ {
+ return true;
+ }
+
+ if (IsLinkTagWithTitle(mdString, startIndex, out var titleLength, out var linkLength))
+ {
+ tagLength = titleLength + linkLength;
+ return true;
+ }
+
+ tagLength = default;
+ return false;
+ }
+
+ public string GetHtmlString(string mdString, int startIndex)
+ {
+ if (IsAngleBracketsLinkTag(mdString, startIndex, out var tagLength))
+ {
+ var link = mdString[(startIndex + 1)..(startIndex + tagLength - 1)];
+ return $"{link}";
+ }
+
+ if (IsLinkTagWithTitle(mdString, startIndex, out var titleLegnth, out var linkLength))
+ {
+ var titleEndIndex = startIndex + titleLegnth;
+ var linkEndIndex = titleEndIndex + linkLength;
+ var title = mdString[(startIndex + 1)..(titleEndIndex - 1)];
+ var link = mdString[(titleEndIndex + 1)..(linkEndIndex - 1)];
+ return $"{title}";
+ }
+
+ throw new InvalidOperationException($"There is no link tag at {startIndex} in '{mdString}'.");
+ }
+
+ public bool IsLinkTagWithTitle(string mdString, int startIndex, out int titleLength, out int linkLength)
+ {
+ linkLength = default;
+ return TagSearchHelper.IsParenthesis(mdString, '[', ']', startIndex, out titleLength)
+ && TagSearchHelper.IsParenthesis(mdString, '(', ')', startIndex + titleLength, out linkLength);
+ }
+
+ private bool IsAngleBracketsLinkTag(string mdString, int startIndex, out int tagLength)
+ {
+ return TagSearchHelper.IsParenthesis(mdString, '<', '>', startIndex, out tagLength);
+ }
+}
diff --git a/cs/Markdown/MdTags/OrderedListHtmlTagsPair.cs b/cs/Markdown/MdTags/OrderedListHtmlTagsPair.cs
new file mode 100644
index 000000000..1bd345cc2
--- /dev/null
+++ b/cs/Markdown/MdTags/OrderedListHtmlTagsPair.cs
@@ -0,0 +1,10 @@
+using Markdown.MdTags.Interfaces;
+
+namespace Markdown.MdTags;
+
+internal class OrderedListHtmlTagsPair : IHtmlTagsPair
+{
+ public string HtmlOpenTag => "";
+
+ public string HtmlCloseTag => "
";
+}
diff --git a/cs/Markdown/MdTags/OrderedListItemMdTag.cs b/cs/Markdown/MdTags/OrderedListItemMdTag.cs
new file mode 100644
index 000000000..9deebcd73
--- /dev/null
+++ b/cs/Markdown/MdTags/OrderedListItemMdTag.cs
@@ -0,0 +1,27 @@
+using Markdown.MdTags.Interfaces;
+
+namespace Markdown.MdTags;
+
+internal class OrderedListItemMdTag : IGroupedMdTag, IHtmlTagsPair
+{
+ private const string MdTagEnd = ".";
+
+ private readonly OrderedListHtmlTagsPair orderedListTags = new();
+
+ public string HtmlOpenTag => "";
+
+ public string HtmlCloseTag => "";
+
+ public TagType TagType => TagType.FullLine;
+
+ public IHtmlTagsPair GroupHtmlTagsPair => orderedListTags;
+
+ public bool IsMdTag(string mdString, int startIndex, out int tagLength)
+ {
+ var isNumber = TagSearchHelper.IsNumber(mdString, startIndex, out var numberLength);
+ tagLength = numberLength + MdTagEnd.Length + 1;
+ return isNumber
+ && TagSearchHelper.IsSubstring(mdString, MdTagEnd, startIndex + numberLength)
+ && TagSearchHelper.IsWhitespaceOrStringEnd(mdString, startIndex + numberLength + MdTagEnd.Length);
+ }
+}
diff --git a/cs/Markdown/MdTags/TagType.cs b/cs/Markdown/MdTags/TagType.cs
new file mode 100644
index 000000000..b8ae922dd
--- /dev/null
+++ b/cs/Markdown/MdTags/TagType.cs
@@ -0,0 +1,24 @@
+namespace Markdown.MdTags;
+
+internal enum TagType
+{
+ ///
+ /// Одиночный тег.
+ ///
+ Normal = 0,
+
+ ///
+ /// Область тега ограничена открывающим и закрывающим тегами.
+ ///
+ Pair = 1,
+
+ ///
+ /// Область тега начинается с начала строки и закрывается переносом строки.
+ ///
+ FullLine = 2,
+
+ ///
+ /// Игнорируемая, неизменяющаяся область.
+ ///
+ Ignore = 3,
+}
\ No newline at end of file
diff --git a/cs/Markdown/MdTags/UnderscoresBetweenNumbersIgnoreTag.cs b/cs/Markdown/MdTags/UnderscoresBetweenNumbersIgnoreTag.cs
new file mode 100644
index 000000000..f22e2f04e
--- /dev/null
+++ b/cs/Markdown/MdTags/UnderscoresBetweenNumbersIgnoreTag.cs
@@ -0,0 +1,27 @@
+using Markdown.MdTags.Interfaces;
+
+namespace Markdown.MdTags;
+
+internal class UnderscoresBetweenNumbersIgnoreTag : IMdTag
+{
+ public TagType TagType => TagType.Ignore;
+
+ public bool IsMdTag(string mdString, int startIndex, out int tagLength)
+ {
+ if (char.IsDigit(mdString[startIndex]))
+ {
+ var i = startIndex + 1;
+
+ for (; i < mdString.Length && mdString[i] == '_'; i++) ;
+
+ if (i < mdString.Length && i > startIndex + 1 && char.IsDigit(mdString[startIndex]))
+ {
+ tagLength = i - startIndex + 1;
+ return true;
+ }
+ }
+
+ tagLength = default;
+ return false;
+ }
+}
diff --git a/cs/Markdown/MdTags/UnorderedListHtmlTagsPair.cs b/cs/Markdown/MdTags/UnorderedListHtmlTagsPair.cs
new file mode 100644
index 000000000..9e475aeb6
--- /dev/null
+++ b/cs/Markdown/MdTags/UnorderedListHtmlTagsPair.cs
@@ -0,0 +1,10 @@
+using Markdown.MdTags.Interfaces;
+
+namespace Markdown.MdTags;
+
+internal class UnorderedListHtmlTagsPair : IHtmlTagsPair
+{
+ public string HtmlOpenTag => "";
+
+ public string HtmlCloseTag => "
";
+}
diff --git a/cs/Markdown/MdTags/UnorderedListItemMdTag.cs b/cs/Markdown/MdTags/UnorderedListItemMdTag.cs
new file mode 100644
index 000000000..b69517201
--- /dev/null
+++ b/cs/Markdown/MdTags/UnorderedListItemMdTag.cs
@@ -0,0 +1,26 @@
+using Markdown.MdTags.Interfaces;
+
+namespace Markdown.MdTags;
+
+internal class UnorderedListItemMdTag : IGroupedMdTag, IHtmlTagsPair
+{
+ private const string MdTag = "-";
+
+ private readonly UnorderedListHtmlTagsPair unorderedListTags = new();
+
+ public string HtmlOpenTag => "";
+
+ public string HtmlCloseTag => "";
+
+ public TagType TagType => TagType.FullLine;
+
+ public IHtmlTagsPair GroupHtmlTagsPair => unorderedListTags;
+
+ public bool IsMdTag(string mdString, int startIndex, out int tagLength)
+ {
+ tagLength = MdTag.Length + 1;
+ return TagSearchHelper.IsNewLineStart(mdString, startIndex)
+ && TagSearchHelper.IsSubstring(mdString, MdTag, startIndex)
+ && TagSearchHelper.IsWhitespaceOrStringEnd(mdString, startIndex + MdTag.Length);
+ }
+}
diff --git a/cs/Markdown/SubstringReplacement.cs b/cs/Markdown/SubstringReplacement.cs
new file mode 100644
index 000000000..6f76d30b7
--- /dev/null
+++ b/cs/Markdown/SubstringReplacement.cs
@@ -0,0 +1,6 @@
+namespace Markdown;
+
+internal record struct SubstringReplacement(int Index, int Length, string? Replacement = null)
+{
+ public readonly int EndIndex => Index + Length;
+}
diff --git a/cs/Markdown/TagReplacementsPair.cs b/cs/Markdown/TagReplacementsPair.cs
new file mode 100644
index 000000000..bcc03282e
--- /dev/null
+++ b/cs/Markdown/TagReplacementsPair.cs
@@ -0,0 +1,19 @@
+using Markdown.MdTags.Interfaces;
+
+namespace Markdown;
+
+internal record struct TagReplacementsPair(SubstringReplacement OpenTag, SubstringReplacement CloseTag)
+{
+ public TagReplacementsPair GetHtmlReplacements(IHtmlTagsPair tag)
+ {
+ return new TagReplacementsPair(
+ OpenTag with { Replacement = tag.HtmlOpenTag },
+ CloseTag with { Replacement = tag.HtmlCloseTag });
+ }
+
+ public readonly IEnumerable Flatten()
+ {
+ yield return OpenTag;
+ yield return CloseTag;
+ }
+}
diff --git a/cs/Markdown/TagSearchHelper.cs b/cs/Markdown/TagSearchHelper.cs
new file mode 100644
index 000000000..c3ba877f9
--- /dev/null
+++ b/cs/Markdown/TagSearchHelper.cs
@@ -0,0 +1,68 @@
+namespace Markdown;
+
+internal static class TagSearchHelper
+{
+ public static bool IsSubstring(string str, string substring, int startIndex)
+ {
+ return startIndex + substring.Length <= str.Length
+ && str.IndexOf(substring, startIndex, substring.Length) == startIndex;
+ }
+
+ public static bool IsNewLineStart(string str, int index)
+ {
+ return index == 0
+ || str[index - 1] == '\n';
+ }
+
+ public static bool IsWhitespaceOrStringEnd(string str, int index)
+ {
+ return index == -1
+ || index == str.Length
+ || char.IsWhiteSpace(str[index]);
+ }
+
+ public static bool IsParenthesis(string str, char open, char close, int startIndex, out int length)
+ {
+ if (startIndex >= 0 && startIndex < str.Length && str[startIndex] == open)
+ {
+ var nextOpenIndex = str.IndexOf(open, startIndex + 1);
+ var closeIndex = nextOpenIndex == -1
+ ? str.IndexOf(close, startIndex + 1)
+ : str.IndexOf(close, startIndex + 1, nextOpenIndex - startIndex);
+
+ if (closeIndex != -1)
+ {
+ length = closeIndex - startIndex + 1;
+ return true;
+ }
+ }
+
+ length = default;
+ return false;
+ }
+
+ public static bool IsNumber(string str, int startIndex, out int length)
+ {
+ length = 0;
+
+ while (startIndex + length < str.Length && char.IsDigit(str[startIndex + length]))
+ {
+ length++;
+ }
+
+ return length != 0;
+ }
+
+ public static bool IsWhiteSpaceSubstring(string str, int startIndex, int length)
+ {
+ for (var i = startIndex; i < startIndex + length; i++)
+ {
+ if (!char.IsWhiteSpace(str[i]))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+}
diff --git a/cs/Markdown_Tests/Benchmark.cs b/cs/Markdown_Tests/Benchmark.cs
new file mode 100644
index 000000000..dc43b94f5
--- /dev/null
+++ b/cs/Markdown_Tests/Benchmark.cs
@@ -0,0 +1,27 @@
+using System.Diagnostics;
+
+namespace Markdown_Tests;
+
+internal class Benchmark
+{
+ public long MeasureMilliseconds(
+ Action measuringAction,
+ int repetitionsCount)
+ {
+ GC.Collect();
+ GC.WaitForPendingFinalizers();
+ measuringAction.Invoke();
+
+ var stopWatch = new Stopwatch();
+ stopWatch.Start();
+
+ for (var i = 0; i < repetitionsCount; i++)
+ {
+ measuringAction.Invoke();
+ }
+
+ stopWatch.Stop();
+
+ return stopWatch.ElapsedMilliseconds;
+ }
+}
diff --git a/cs/Markdown_Tests/Markdown_Tests.csproj b/cs/Markdown_Tests/Markdown_Tests.csproj
new file mode 100644
index 000000000..918012601
--- /dev/null
+++ b/cs/Markdown_Tests/Markdown_Tests.csproj
@@ -0,0 +1,29 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/cs/Markdown_Tests/MdTests.cs b/cs/Markdown_Tests/MdTests.cs
new file mode 100644
index 000000000..c2e59f5f6
--- /dev/null
+++ b/cs/Markdown_Tests/MdTests.cs
@@ -0,0 +1,233 @@
+using FluentAssertions;
+using Markdown;
+
+namespace Markdown_Tests;
+
+public class MdTests
+{
+ private IStringProcessor md;
+
+ [SetUp]
+ public void Setup()
+ {
+ md = new Md();
+ }
+
+ [Test]
+ public void Render_ThrowsException_WhenArgumentIsNull()
+ {
+ var render = () => md.Render(null!);
+ render.Should().Throw();
+ }
+
+ [TestCase("")]
+ [TestCase("abc")]
+ [TestCase("a b")]
+ [TestCase("a\nb")]
+ [Description("Не изменяет строку без тегов")]
+ public void Render_ReturnsSameText_WhenWithoutTags(string mdString)
+ {
+ var htmlString = md.Render(mdString);
+ htmlString.Should().Be(mdString);
+ }
+
+ [TestCaseSource(nameof(GetTextsWithValidPairTags), new object[] { "_", "em" })]
+ [TestCaseSource(nameof(GetTextsWithInvalidPairTags), new object[] { "_", "em" })]
+ [Description("Курсив")]
+ public void Render_WorksWithItalicTextTag(string mdString, string expectedHtmlString)
+ {
+ var actualHtmlString = md.Render(mdString);
+ actualHtmlString.Should().Be(expectedHtmlString);
+ }
+
+ [TestCaseSource(nameof(GetTextsWithValidPairTags), new object[] { "__", "strong" })]
+ [TestCaseSource(nameof(GetTextsWithInvalidPairTags), new object[] { "__", "strong" })]
+ [Description("Жирный текст")]
+ public void Render_WorksWithBoldTextTag(string mdString, string expectedHtmlString)
+ {
+ var actualHtmlString = md.Render(mdString);
+ actualHtmlString.Should().Be(expectedHtmlString);
+ }
+
+ [TestCase("# a", "a
")]
+ [TestCase("a\n# b", "a\nb
")]
+ [TestCase("# a\nb", "a
\nb", Description = "В заголовок входят символы до конца строки")]
+ [TestCase("a # b", "a # b", Description = "Заголовок должен быть в начале строки")]
+ [Description("Заголовок")]
+ public void Render_WorksWithHeaderTag(string mdString, string expectedHtmlString)
+ {
+ var actualHtmlString = md.Render(mdString);
+ actualHtmlString.Should().Be(expectedHtmlString);
+ }
+
+ [TestCase("", "a")]
+ [TestCase("[a](b)", "a")]
+ [Description("Ссылка")]
+ public void Render_WorksWithLinkTag(string mdString, string expectedHtmlString)
+ {
+ var actualHtmlString = md.Render(mdString);
+ actualHtmlString.Should().Be(expectedHtmlString);
+ }
+
+ [TestCase("![a](b)", "")]
+ [Description("Изображение")]
+ public void Render_WorksWithImageTag(string mdString, string expectedHtmlString)
+ {
+ var actualHtmlString = md.Render(mdString);
+ actualHtmlString.Should().Be(expectedHtmlString);
+ }
+
+ [TestCase("1. a\n2. b", "- a
\n- b
")]
+ [TestCase("12345. a\n99999. b", "- a
\n- b
")]
+ [TestCase("1. a\n2. b\nc", "- a
\n- b
\nc")]
+ [TestCase("1. a\n2. b\nc\n1. d", "- a
\n- b
\nc\n- d
")]
+ [Description("Нумерованный список")]
+ public void Render_WorksWithOrderedListTag(string mdString, string expectedHtmlString)
+ {
+ var actualHtmlString = md.Render(mdString);
+ actualHtmlString.Should().Be(expectedHtmlString);
+ }
+
+ [TestCase("- a\n- b", "")]
+ [TestCase("- a\n- b\nc", "\nc")]
+ [TestCase("- a\n- b\nc\n- d", "\nc")]
+ [Description("Ненумерованный список")]
+ public void Render_WorksWithUnorderedListTag(string mdString, string expectedHtmlString)
+ {
+ var actualHtmlString = md.Render(mdString);
+ actualHtmlString.Should().Be(expectedHtmlString);
+ }
+
+ [TestCase(@"\a", @"\a")]
+ [TestCase(@"\_a_", @"_a_")]
+ [TestCase(@"\__a__", @"__a__")]
+ [TestCase(@"\# a", @"# a")]
+ [TestCase(@"\", @"")]
+ [TestCase(@"\[a](b)", @"[a](b)")]
+ [TestCase(@"\- a", @"- a")]
+ [TestCase(@"\\_a_", @"\a")]
+ [TestCase(@"\__a_", @"_a")]
+ [TestCase(@"\![a](b)", @"!a")]
+ [Description("Экранирование")]
+ public void Render_IgnoresEscapedTags(string mdString, string expectedHtmlString)
+ {
+ var actualHtmlString = md.Render(mdString);
+ actualHtmlString.Should().Be(expectedHtmlString);
+ }
+
+ [Test]
+ [Description("Часть двойного подчеркивания не считается парой к одиночному")]
+ public void Render_IgnoresInvalidPairTags()
+ {
+ var mdString = "__a_";
+ var htmlString = md.Render(mdString);
+ htmlString.Should().Be(mdString);
+ }
+
+ [TestCase("# a _b_", "a b
")]
+ [TestCase("# a __b__", "a b
")]
+ [Description("Курсив и жирный текст внутри заголовка работают")]
+ public void Render_ItalicAndBoldTextTagsWorkInsideHeader(string mdString, string expectedHtmlString)
+ {
+ var actualHtmlString = md.Render(mdString);
+ actualHtmlString.Should().Be(expectedHtmlString);
+ }
+
+ [TestCase("__a _b_ c__", "a b c")]
+ [TestCase("__a_b_c__", "abc")]
+ [Description("Курсив внутри жирного текста работает")]
+ public void Render_ItalicTextTagsWorkInsideBoldText(string mdString, string expectedHtmlString)
+ {
+ var actualHtmlString = md.Render(mdString);
+ actualHtmlString.Should().Be(expectedHtmlString);
+ }
+
+ [TestCase("_a __b__ c_", "a __b__ c")]
+ [TestCase("_a__b__c_", "a__b__c")]
+ [Description("Жирный текст внутри курсива не работает")]
+ public void Render_BoldTextTagsDoNotWorkInsideItalicText(string mdString, string expectedHtmlString)
+ {
+ var actualHtmlString = md.Render(mdString);
+ actualHtmlString.Should().Be(expectedHtmlString);
+ }
+
+ [TestCase("a_b1_2")]
+ [TestCase("a__b1__2")]
+ [TestCase("a_b1___2")]
+ [Description("Окруженные цифрами подчеркивания не считаются тегами")]
+ public void Render_IgnoresUnderscoresSurroundedByNumbers(string mdString)
+ {
+ var htmlString = md.Render(mdString);
+ htmlString.Should().Be(mdString);
+ }
+
+ [TestCase("__a _b c__ d_")]
+ [TestCase("___a___")]
+ [Description("В случае пересечения областей парных тегов, они не считаются тегами")]
+ public void Render_PairTagsDoNotWork_WhenIntersected(string mdString)
+ {
+ var htmlString = md.Render(mdString);
+ htmlString.Should().Be(mdString);
+ }
+
+ [Test]
+ [Description("Работает за линейное время")]
+ public void Render_PerformanceTest()
+ {
+ var mdString = "__a__ _b_\n# c \\__d__ [f](g) ![h](k)\n1. l\n2. m\n- n\n o";
+ var minRepetitionsCount = 64;
+ var maxRepetitionsCount = 512;
+ var step = 2;
+ var error = 1;
+
+ var benchmark = new Benchmark();
+ var previousTime = 0L;
+
+ for (var count = minRepetitionsCount; count <= maxRepetitionsCount; count *= step)
+ {
+ var arg = string.Concat(Enumerable.Repeat(mdString, count));
+ var time = benchmark.MeasureMilliseconds(() => md.Render(arg), 100);
+
+ if (previousTime != 0)
+ {
+ time.Should().BeLessThan((step + error) * previousTime);
+ }
+
+ previousTime = time;
+ }
+ }
+
+ private static IEnumerable GetTextsWithValidPairTags(string mdTag, string htmlTag)
+ {
+ yield return new TestCaseData($"{mdTag}a{mdTag}", $"<{htmlTag}>a{htmlTag}>");
+ yield return new TestCaseData($"{mdTag}a b{mdTag}", $"<{htmlTag}>a b{htmlTag}>");
+ yield return new TestCaseData($"{mdTag}a\nb{mdTag}", $"<{htmlTag}>a\nb{htmlTag}>");
+ yield return new TestCaseData($"a {mdTag}b{mdTag} c", $"a <{htmlTag}>b{htmlTag}> c");
+ yield return new TestCaseData($"a{mdTag}b{mdTag}c", $"a<{htmlTag}>b{htmlTag}>c");
+ yield return new TestCaseData($"{mdTag}a{mdTag}b", $"<{htmlTag}>a{htmlTag}>b");
+ yield return new TestCaseData($"a{mdTag}b{mdTag}", $"a<{htmlTag}>b{htmlTag}>");
+ yield return new TestCaseData($"{mdTag}a{mdTag}b{mdTag}c{mdTag}", $"<{htmlTag}>a{htmlTag}>b<{htmlTag}>c{htmlTag}>");
+ }
+
+ private static IEnumerable GetTextsWithInvalidPairTags(string mdTag, string htmlTag)
+ {
+ yield return new TestCaseData($"{mdTag}a", $"{mdTag}a")
+ .SetDescription("Только открывающий тег не считается тегом");
+ yield return new TestCaseData($"a{mdTag}", $"a{mdTag}")
+ .SetDescription("Только закрывающий тег не считается тегом");
+ yield return new TestCaseData($"{mdTag}{mdTag}", $"{mdTag}{mdTag}")
+ .SetDescription("Если между парными тегами пустая строка, они не считаются тегами");
+ yield return new TestCaseData($"{mdTag}a {mdTag}b", $"{mdTag}a {mdTag}b")
+ .SetDescription("Закрывающий тег не может иметь пробел перед ним");
+ yield return new TestCaseData($"a{mdTag} b{mdTag}", $"a{mdTag} b{mdTag}")
+ .SetDescription("Открывающий тег не может иметь пробел после него");
+ yield return new TestCaseData($"a{mdTag}b c{mdTag}d", $"a{mdTag}b c{mdTag}d")
+ .SetDescription("Теги в разных словах не считаются парой тегов");
+ yield return new TestCaseData($"{mdTag}a {mdTag}b{mdTag}", $"{mdTag}a <{htmlTag}>b{htmlTag}>")
+ .SetDescription("Парой к закрывающему тегу считается тот открывающий тег, который ближе");
+ yield return new TestCaseData($"{mdTag}a{mdTag} b{mdTag}", $"<{htmlTag}>a{htmlTag}> b{mdTag}")
+ .SetDescription("Парой к открывающему тегу считается тот закрывающий тег, который ближе");
+ yield return new TestCaseData($"{mdTag}a{mdTag}b{mdTag}", $"<{htmlTag}>a{htmlTag}>b{mdTag}")
+ .SetDescription("Если в одном слове нечетное число тегов, парой считаются те, что левее");
+ }
+}
diff --git a/cs/clean-code.sln b/cs/clean-code.sln
index 2206d54db..59d91688a 100644
--- a/cs/clean-code.sln
+++ b/cs/clean-code.sln
@@ -1,13 +1,17 @@
Microsoft Visual Studio Solution File, Format Version 12.00
-# Visual Studio 14
-VisualStudioVersion = 14.0.25420.1
+# Visual Studio Version 17
+VisualStudioVersion = 17.11.35327.3
MinimumVisualStudioVersion = 10.0.40219.1
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Chess", "Chess\Chess.csproj", "{DBFBE40E-EE0C-48F4-8763-EBD11C960081}"
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Chess", "Chess\Chess.csproj", "{DBFBE40E-EE0C-48F4-8763-EBD11C960081}"
EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ControlDigit", "ControlDigit\ControlDigit.csproj", "{B06A4B35-9D61-4A63-9167-0673F20CA989}"
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ControlDigit", "ControlDigit\ControlDigit.csproj", "{B06A4B35-9D61-4A63-9167-0673F20CA989}"
EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Samples", "Samples\Samples.csproj", "{C3EF41D7-50EF-4CE1-B30A-D1D81C93D7FA}"
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Samples", "Samples\Samples.csproj", "{C3EF41D7-50EF-4CE1-B30A-D1D81C93D7FA}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Markdown", "Markdown\Markdown.csproj", "{7352C911-096E-432D-A214-D40B1C919664}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Markdown_Tests", "Markdown_Tests\Markdown_Tests.csproj", "{091EDA1C-DDC2-45B2-963C-4B68E35F619D}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@@ -27,5 +31,19 @@ Global
{C3EF41D7-50EF-4CE1-B30A-D1D81C93D7FA}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C3EF41D7-50EF-4CE1-B30A-D1D81C93D7FA}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C3EF41D7-50EF-4CE1-B30A-D1D81C93D7FA}.Release|Any CPU.Build.0 = Release|Any CPU
+ {7352C911-096E-432D-A214-D40B1C919664}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {7352C911-096E-432D-A214-D40B1C919664}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {7352C911-096E-432D-A214-D40B1C919664}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {7352C911-096E-432D-A214-D40B1C919664}.Release|Any CPU.Build.0 = Release|Any CPU
+ {091EDA1C-DDC2-45B2-963C-4B68E35F619D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {091EDA1C-DDC2-45B2-963C-4B68E35F619D}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {091EDA1C-DDC2-45B2-963C-4B68E35F619D}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {091EDA1C-DDC2-45B2-963C-4B68E35F619D}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(ExtensibilityGlobals) = postSolution
+ SolutionGuid = {A36FC138-9295-48C7-A558-42B959582E3A}
EndGlobalSection
EndGlobal