Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion MarkdownSpec.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,4 +70,8 @@ __Непарные_ символы в рамках одного абзаца н

превратится в:

\<h1>Заголовок \<strong>с \<em>разными\</em> символами\</strong>\</h1>
\<h1>Заголовок \<strong>с \<em>разными\</em> символами\</strong>\</h1>

# Маркированный список

Абзацы начиная с "- ", выделяются тегом \<li> до конца абзаца. Внутри может быть другая разметка. Может быть обхвачен заголовком.
41 changes: 41 additions & 0 deletions cs/Markdown/Data/Marks.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
namespace Markdown.Data;

public static class Marks
{
public const string Bold = "__";
public const string Italic = "_";
public const string Header = "#";
public const string List = "-";

public static readonly IEnumerable<string> AllMarks = new[]
{
Bold,
Italic,
Header,
List
};

public static string GetMarkByTagName(string name)
{
switch (name)
{
case TagNames.Header:
return Header;
case TagNames.Strong:
return Bold;
case TagNames.Em:
return Italic;
case TagNames.List:
return List;
default:
throw new ArgumentException("Wrong name!");
}
}

public static int AfterMarkSpace(string mark)
{
if (mark == Header || mark == List)
return 1;
return 0;
}
}
16 changes: 16 additions & 0 deletions cs/Markdown/Data/PositionedTag.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
namespace Markdown.Data;

public class PositionedTag : Tag
{
public int Position { get; }
public bool IsOpenClose { get; }
public bool IsOpening { get; }
public string Mark => Marks.GetMarkByTagName(Name);

public PositionedTag(int position, string mark, bool isOpening = true, bool isOpenClose = false) : base(TagFactory.BuildTag(mark).Name)
{
IsOpenClose = isOpenClose;
IsOpening = isOpening;
Position = position;
}
}
13 changes: 13 additions & 0 deletions cs/Markdown/Data/Tag.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
namespace Markdown.Data;

public class Tag
{
public string Name { get; }
public string OpenTag => $"<{Name}>";
public string CloseTag => $"</{Name}>";

public Tag(string name)
{
Name = name;
}
}
33 changes: 33 additions & 0 deletions cs/Markdown/Data/TagFactory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
namespace Markdown.Data;

public static class TagNames {
public const string Strong = "strong";
public const string Em = "em";
public const string Header = "h1";
public const string List = "li";
}

public static class TagFactory
{
private static Tag _bold => new(TagNames.Strong);
private static Tag _italic => new(TagNames.Em);
private static Tag _header => new(TagNames.Header);
private static Tag _list => new(TagNames.List);

public static Tag BuildTag(string mark)
{
switch (mark)
{
case Marks.Header:
return _header;
case Marks.Bold:
return _bold;
case Marks.Italic:
return _italic;
case Marks.List:
return _list;
default:
throw new ArgumentException("Wrong mark!");
}
}
}
35 changes: 35 additions & 0 deletions cs/Markdown/Data/Token.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
namespace Markdown.Data;

public class Token
{
private Tag _tag;
private string _mark => Marks.GetMarkByTagName(_tag.Name);
public string Head => _tag.OpenTag;
public string Tail => _tag.CloseTag;
public int StartPosition { get; }
public int EndPosition { get; }

public int Gap(bool isOpened)
{
if (_mark == Marks.Header || _mark == Marks.List)
{
if (isOpened)
{
return _mark.Length + 1;
}
return 0;
}
return _mark.Length;
}

public Token(Tag tag, int start, int end)
{
this._tag = tag;
StartPosition = start;
EndPosition = end;
}

public Token(string mark, int start, int end) : this(TagFactory.BuildTag(mark), start, end)
{
}
}
15 changes: 15 additions & 0 deletions cs/Markdown/Markdown.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net6</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="NUnit" Version="4.4.0" />
<PackageReference Include="NUnit3TestAdapter" Version="5.2.0" />
<PackageReference Include="FluentAssertions" Version="8.8.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.7.1" />
</ItemGroup>

</Project>
54 changes: 54 additions & 0 deletions cs/Markdown/Md.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
using System.Text;
using Markdown.Data;

namespace Markdown;

public class Md
{
public static string Render(string input)
{
var parser = new TokenParser();
var tokens = parser.ParseTokens(input);

return GenerateHtml(input, tokens);
}

public static string GenerateHtml(string text, IEnumerable<Token> tokens)
{
var mergedText = new StringBuilder();

var tokenStack = new Stack<Token>();
var prevPosition = 0;
var outerEnd = text.Length + 3;
foreach (var token in tokens)
{
if (token.StartPosition >= outerEnd)
{
while (tokenStack.Count > 0)
{
var tokenPrev = tokenStack.Pop();
mergedText.Append(text.Substring(prevPosition,tokenPrev.EndPosition - prevPosition));
mergedText.Append(tokenPrev.Tail);
prevPosition = tokenPrev.EndPosition + tokenPrev.Gap(false);
}
}
mergedText.Append(text.Substring(prevPosition,token.StartPosition-prevPosition));
mergedText.Append(token.Head);
prevPosition = token.StartPosition + token.Gap(true);
outerEnd = token.EndPosition + token.Gap(false);
tokenStack.Push(token);
}

while (tokenStack.Count > 0)
{
var token = tokenStack.Pop();
mergedText.Append(text.Substring(prevPosition,token.EndPosition - prevPosition));
mergedText.Append(token.Tail);
prevPosition = token.EndPosition + token.Gap(false);
}
mergedText.Append(text.Substring(prevPosition, text.Length - prevPosition));
return mergedText.ToString();
}
}


63 changes: 63 additions & 0 deletions cs/Markdown/ParserValidator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
namespace Markdown;
using Markdown.Data;

public class ParserValidator
{
private string _text;

public ParserValidator(string input)
{
_text = input;
}

public bool IsMarkCorrect(int startIndex, bool isOpening, int markLength = 1)
{
var isScreened = IsScreened(startIndex);
if (isOpening)
{
return !isScreened
&& startIndex + markLength < _text.Length
&& !Char.IsWhiteSpace(_text[startIndex + markLength]);
}
return !isScreened
&& startIndex > 0
&& !Char.IsWhiteSpace(_text[startIndex - 1]);
}

public bool IsScreened(int index)
{
return (index > 0 && _text[index - 1] == '\\')
&& (index > 1 && _text[index - 2] != '\\' || index == 1);
}

public bool IsDoubleUnderscore(int index)
{
return index < _text.Length - 1 && _text[index + 1] == '_'
|| index > 0 && _text[index - 1] == '_';
}

public bool IsContentAcceptable(string content, string mark)
{
return !string.IsNullOrEmpty(content) && (HasNoDigits(content) || mark == Marks.Header || mark == Marks.List);
}

public bool IsSplittingWords(int start, int end)
{
return start > 0 && _text[start - 1] != ' '
&& end < _text.Length - 1 && _text[end + 1] != ' '
&& _text.Substring(start, end - start).Any(Char.IsWhiteSpace)
&& _text[end] != '\n';
}

private bool HasNoDigits(string content)
{
foreach (char c in content)
{
if (char.IsDigit(c))
return false;
}
return true;
}


}
101 changes: 101 additions & 0 deletions cs/Markdown/Tests/Markdown_Tests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
using System.Diagnostics;
using FluentAssertions;
using NUnit.Framework;
using System.Text;
using Markdown.Data;

namespace Markdown.Tests;

[TestFixture]
class Markdown_Tests
{
public static IEnumerable<TestCaseData> GenerateHtmlSource()
{
yield return new TestCaseData(
"# main title\n__some bold text__",
"<h1>main title</h1>\n<strong>some bold text</strong>",
new []
{
new Token(Marks.Header, 0, 12),
new Token(Marks.Bold, 13, 29)
}
).SetName("Simple text");
yield return new TestCaseData(
"# main title\n__some _bold_ text__",
"<h1>main title</h1>\n<strong>some <em>bold</em> text</strong>",
new []
{
new Token(Marks.Header, 0, 12),
new Token(Marks.Bold, 13, 31),
new Token(Marks.Italic, 20, 25)
}
).SetName("Token inside token");
}

[Test, TestCaseSource(nameof(GenerateHtmlSource))]
public void GenerateHtml_DifferentText(string actual, string expected, Token[] tokens)
{
Md.GenerateHtml(actual, tokens).Should().Be(expected);
}

public static IEnumerable<TestCaseData> Render_Source()
{
yield return new TestCaseData(
"п# з __ж _к_ ж__ з\nп",
"п<h1>з <strong>ж <em>к</em> ж</strong> з</h1>\nп"
).SetName("Small text for debugging");
yield return new TestCaseData(
"# Заголовок __с _разными_ символами__",
"<h1>Заголовок <strong>с <em>разными</em> символами</strong></h1>"
).SetName("Simple text");
yield return new TestCaseData(
"- __bold__\n- _italic_\n- # header",
"<li><strong>bold</strong></li>\n<li><em>italic</em></li>\n<li><h1>header</h1></li>"
).SetName("Simple list");
yield return new TestCaseData(
"# Заголовок с# заголовком\n\n _нач_ало се__реди__на ко_нец_ а __также _вложенные_ тэги __сам_ых__ раз_ных__ видов__",
"<h1>Заголовок с<h1>заголовком</h1></h1>\n\n <em>нач</em>ало се<strong>реди</strong>на ко<em>нец</em> а __также <em>вложенные</em> тэги __сам_ых__ раз_ных__ видов__"
).SetName("Normal text");
yield return new TestCaseData(
"# Спецификация языка разметки\n\nПосмотрите этот файл в сыром виде. Сравните с тем, что показывает github.\nВсе совпадения случайны ;)\n\n\n\n# Курсив\n\nТекст, _окруженный с двух сторон_ одинарными символами подчерка,\nдолжен помещаться в HTML-тег \\<em> вот так:\n\nТекст, \\<em>окруженный с двух сторон\\</em> одинарными символами подчерка,\nдолжен помещаться в HTML-тег \\<em>.\n\n\n\n# Полужирный\n\n__Выделенный двумя символами текст__ должен становиться полужирным с помощью тега \\<strong>.\n\n\n\n# Экранирование\n\nЛюбой символ можно экранировать, чтобы он не считался частью разметки.\n\\_Вот это\\_, не должно выделиться тегом \\<em>.\n\nСимвол экранирования исчезает из результата, только если экранирует что-то.\nЗдесь сим\\волы экранирования\\ \\должны остаться.\\\n\nСимвол экранирования тоже можно экранировать: \\\\_вот это будет выделено тегом_ \\<em>\n\n\n\n# Взаимодействие тегов\n\nВнутри __двойного выделения _одинарное_ тоже__ работает.\n\nНо не наоборот — внутри _одинарного __двойное__ не_ работает.\n\nПодчерки внутри текста c цифрами_12_3 не считаются выделением и должны оставаться символами подчерка.\n\nОднако выделять часть слова они могут: и в _нач_але, и в сер_еди_не, и в кон_це._\n\nВ то же время выделение в ра_зных сл_овах не работает.\n\n__Непарные_ символы в рамках одного абзаца не считаются выделением.\n\nЗа подчерками, начинающими выделение, должен следовать непробельный символ. Иначе эти_ подчерки_ не считаются выделением \nи остаются просто символами подчерка.\n\nПодчерки, заканчивающие выделение, должны следовать за непробельным символом. Иначе эти _подчерки _не считаются_ окончанием выделения \nи остаются просто символами подчерка.\n\nВ случае __пересечения _двойных__ и одинарных_ подчерков ни один из них не считается выделением.\n\nЕсли внутри подчерков пустая строка ____, то они остаются символами подчерка.\n\n\n\n# Заголовки\n\nАбзац, начинающийся с '\\# ', выделяется тегом \\<h1> в заголовок.\nВ тексте заголовка могут присутствовать все прочие символы разметки с указанными правилами.\n\nТаким образом\n\n# Заголовок __с _разными_ символами__\n\nпревратится в:\n\n\\<h1>Заголовок \\<strong>с \\<em>разными\\</em> символами\\</strong>\\</h1>",
"<h1>Спецификация языка разметки</h1>\n\nПосмотрите этот файл в сыром виде. Сравните с тем, что показывает github.\nВсе совпадения случайны ;)\n\n\n\n<h1>Курсив</h1>\n\nТекст, <em>окруженный с двух сторон</em> одинарными символами подчерка,\nдолжен помещаться в HTML-тег \\<em> вот так:\n\nТекст, \\<em>окруженный с двух сторон\\</em> одинарными символами подчерка,\nдолжен помещаться в HTML-тег \\<em>.\n\n\n\n<h1>Полужирный</h1>\n\n__Выделенный двумя символами текст__ должен становиться полужирным с помощью тега \\<strong>.\n\n\n\n<h1>Экранирование</h1>\n\nЛюбой символ можно экранировать, чтобы он не считался частью разметки.\n\\_Вот это\\_, не должно выделиться тегом \\<em>.\n\nСимвол экранирования исчезает из результата, только если экранирует что-то.\nЗдесь сим\\волы экранирования\\ \\должны остаться.\\\n\nСимвол экранирования тоже можно экранировать: \\\\_вот это будет выделено тегом_ \\<em>\n\n\n\n<h1>Взаимодействие тегов</h1>\n\nВнутри <strong>двойного выделения <em>одинарное</em> тоже</strong> работает.\n\nНо не наоборот — внутри <em>одинарного __двойное__ не</em> работает.\n\nПодчерки внутри текста c цифрами_12_3 не считаются выделением и должны оставаться символами подчерка.\n\nОднако выделять часть слова они могут: и в <em>нач</em>але, и в сер<em>еди</em>не, и в кон<em>це.</em>\n\nВ то же время выделение в ра_зных сл_овах не работает.\n\n__Непарные_ символы в рамках одного абзаца не считаются выделением.\n\nЗа подчерками, начинающими выделение, должен следовать непробельный символ. Иначе эти_ подчерки_ не считаются выделением \nи остаются просто символами подчерка.\n\nПодчерки, заканчивающие выделение, должны следовать за непробельным символом. Иначе эти _подчерки <em>не считаются</em> окончанием выделения \nи остаются просто символами подчерка.\n\nВ случае __пересечения _двойных__ и одинарных_ подчерков ни один из них не считается выделением.\n\nЕсли внутри подчерков пустая строка ____, то они остаются символами подчерка.\n\n\n\n<h1>Заголовки</h1>\n\nАбзац, начинающийся с \'\\# \', выделяется тегом \\<h1> в заголовок.\nВ тексте заголовка могут присутствовать все прочие символы разметки с указанными правилами.\n\nТаким образом\n\n<h1>Заголовок <strong>с <em>разными</em> символами</strong></h1>\n\nпревратится в:\n\n\\<h1>Заголовок \\<strong>с \\<em>разными\\</em> символами\\</strong>\\</h1>"
).SetName("Biggest text");
}

[Test, TestCaseSource(nameof(Render_Source))]
public void Render_DifferentText(string actual, string expected)
{
Md.Render(actual).Should().Be(expected);
}

[Test, Explicit]
[TestCase(10000, 10)]
[TestCase(10000, 1000)]
public void ЕfficiencyTest(long n, long coefficient)
{
var input = StackString(n);
var stopwatch = Stopwatch.StartNew();
var res = Md.Render(input);
stopwatch.Stop();
var time1 = stopwatch.ElapsedMilliseconds * coefficient;
Console.WriteLine("Normal text time: " + stopwatch.ElapsedMilliseconds);
input = StackString(n * coefficient);
stopwatch = Stopwatch.StartNew();
res = Md.Render(input);
stopwatch.Stop();
var time2 = stopwatch.ElapsedMilliseconds;
Console.WriteLine("Text * koef time: " + stopwatch.ElapsedMilliseconds);
time2.Should().BeLessThan(time1);
}

private string StackString(long times)
{
var sb = new StringBuilder();
for (int i = 0; i < times; i++)
{
sb.Append("__a");
}
sb.Append("__");
return sb.ToString();
}
}
Loading