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. Три + +Превращается в \
    \
  1. Один\
  2. \
  3. Два\
  4. \
  5. Три\
  6. \
+ +Аналогично, стоящий в начале строки знак '-' выделяет элемент ненумерованного списка: + +- Один +- Два +- Три + +Превращается в \ 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 $"\"{title}\""; + } + + 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\n

    b

    ")] + [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)", "\"a\"")] + [Description("Изображение")] + public void Render_WorksWithImageTag(string mdString, string expectedHtmlString) + { + var actualHtmlString = md.Render(mdString); + actualHtmlString.Should().Be(expectedHtmlString); + } + + [TestCase("1. a\n2. b", "
    1. a
    2. \n
    3. b
    ")] + [TestCase("12345. a\n99999. b", "
    1. a
    2. \n
    3. b
    ")] + [TestCase("1. a\n2. b\nc", "
    1. a
    2. \n
    3. b
    \nc")] + [TestCase("1. a\n2. b\nc\n1. d", "
    1. a
    2. \n
    3. b
    \nc
      \n
    1. d
    ")] + [Description("Нумерованный список")] + public void Render_WorksWithOrderedListTag(string mdString, string expectedHtmlString) + { + var actualHtmlString = md.Render(mdString); + actualHtmlString.Should().Be(expectedHtmlString); + } + + [TestCase("- a\n- b", "
    • a
    • \n
    • b
    ")] + [TestCase("- a\n- b\nc", "
    • a
    • \n
    • b
    \nc")] + [TestCase("- a\n- b\nc\n- d", "
    • a
    • \n
    • b
    \nc
      \n
    • d
    ")] + [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"); + yield return new TestCaseData($"{mdTag}a b{mdTag}", $"<{htmlTag}>a b"); + yield return new TestCaseData($"{mdTag}a\nb{mdTag}", $"<{htmlTag}>a\nb"); + yield return new TestCaseData($"a {mdTag}b{mdTag} c", $"a <{htmlTag}>b c"); + yield return new TestCaseData($"a{mdTag}b{mdTag}c", $"a<{htmlTag}>bc"); + yield return new TestCaseData($"{mdTag}a{mdTag}b", $"<{htmlTag}>ab"); + yield return new TestCaseData($"a{mdTag}b{mdTag}", $"a<{htmlTag}>b"); + yield return new TestCaseData($"{mdTag}a{mdTag}b{mdTag}c{mdTag}", $"<{htmlTag}>ab<{htmlTag}>c"); + } + + 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") + .SetDescription("Парой к закрывающему тегу считается тот открывающий тег, который ближе"); + yield return new TestCaseData($"{mdTag}a{mdTag} b{mdTag}", $"<{htmlTag}>a b{mdTag}") + .SetDescription("Парой к открывающему тегу считается тот закрывающий тег, который ближе"); + yield return new TestCaseData($"{mdTag}a{mdTag}b{mdTag}", $"<{htmlTag}>ab{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