From 0c7fb2ee56d7fb9ef4aea3839c6d60307be84987 Mon Sep 17 00:00:00 2001
From: Dmitriy Pleshevskiy <dmitriy@ideascup.me>
Date: Mon, 13 Jun 2022 17:55:47 +0300
Subject: [PATCH] par(md): add simple list

---
 par/md.test.ts |  67 ++++++++++++++++++++++++++++++++
 par/md.ts      | 102 +++++++++++++++++++++++++++++++++++++++++++++----
 2 files changed, 161 insertions(+), 8 deletions(-)

diff --git a/par/md.test.ts b/par/md.test.ts
index 08e18e8..46dbf84 100644
--- a/par/md.test.ts
+++ b/par/md.test.ts
@@ -191,3 +191,70 @@ Deno.test({
     );
   },
 });
+
+// List
+
+Deno.test({
+  name: "should parse list with empty items",
+  fn: () => {
+    const par = new MarkdownParser();
+
+    assertEquals(
+      ren.render(par.parse("-")),
+      "<ul><li></li></ul>",
+    );
+    assertEquals(
+      ren.render(par.parse("- ")),
+      "<ul><li></li></ul>",
+    );
+  },
+});
+
+Deno.test({
+  name: "should parse list if line contains additional spaces",
+  fn: () => {
+    const expected = "<ul><li>hello</li></ul>";
+    const par = new MarkdownParser();
+    assertEquals(ren.render(par.parse(" - hello")), expected);
+    assertEquals(ren.render(par.parse("  - hello")), expected);
+    assertEquals(ren.render(par.parse("   - hello")), expected);
+  },
+});
+
+Deno.test({
+  name: "should not display a single paragraph in the list",
+  fn: () => {
+    const par = new MarkdownParser();
+
+    assertEquals(
+      ren.render(par.parse("- hello")),
+      "<ul><li>hello</li></ul>",
+    );
+    assertEquals(
+      ren.render(par.parse(`\
+- hello
+world`)),
+      "<ul><li>hello world</li></ul>",
+    );
+    assertEquals(
+      ren.render(par.parse(`\
+- hello
+  world`)),
+      "<ul><li>hello world</li></ul>",
+    );
+  },
+});
+
+Deno.test({
+  name: "should parse many items in the list",
+  fn: () => {
+    const par = new MarkdownParser();
+
+    assertEquals(
+      ren.render(par.parse(`\
+- hello
+- world`)),
+      "<ul><li>hello</li><li>world</li></ul>",
+    );
+  },
+});
diff --git a/par/md.ts b/par/md.ts
index 4de23f4..78987fc 100644
--- a/par/md.ts
+++ b/par/md.ts
@@ -7,6 +7,8 @@ const RE_EMPTY_LINE = /^\s*$/;
 const RE_OPEN_ATX_HEADING = /^\s{0,3}(#{1,6})(\s|$)/;
 const RE_CLOSE_ATX_HEADING = /(^|\s+)#*\s*$/;
 
+const RE_LIST_ITEM = /^\s{0,3}([-+*])(\s|$)/;
+
 // TODO: make better regex for destination
 const RE_LINK = /\[([\s\S]*?)]\((?:([^\s]*)|<(.+?)>)(?: ('|")(.+?)\4)?\)/;
 
@@ -18,6 +20,7 @@ export class MarkdownParser implements Parser {
     while (readStr.length) {
       const newReadStr = skipEmptyLine(readStr) ??
         parseAtxHeading(astDoc, readStr) ??
+        parseList(astDoc, readStr) ??
         parseParagraph(astDoc, readStr);
       if (isNil(newReadStr)) break;
       readStr = newReadStr;
@@ -27,12 +30,33 @@ export class MarkdownParser implements Parser {
   }
 }
 
-function DocChild(content: AstDocumentChild): Elem {
-  switch (content.kind) {
+function List(ast: AstList): Elem {
+  // switch (ast.kind)
+  return BulletList(ast);
+}
+
+function BulletList(ast: AstBulletList): Elem {
+  return new Elem("ul", {}, ast.content.map(ListItem));
+}
+
+function ListItem(ast: AstListItem): Elem {
+  return new Elem(
+    "li",
+    {},
+    ast.content.length === 1 && ast.content[0].kind === AstKind.Paragraph
+      ? ast.content[0].content.map(InlineContent)
+      : ast.content.map(DocChild),
+  );
+}
+
+function DocChild(ast: AstDocumentChild): Elem {
+  switch (ast.kind) {
     case AstKind.AtxHeading:
-      return Heading(content);
+      return Heading(ast);
     case AstKind.Paragraph:
-      return Paragraph(content);
+      return Paragraph(ast);
+    case AstKind.List:
+      return List(ast);
   }
 }
 
@@ -97,7 +121,45 @@ function parseAtxHeading(ast: AstDocument, readStr: string): string | null {
   );
 }
 
-function parseParagraph(ast: AstDocument, readStr: string): string | null {
+function parseList(ast: AstDocument, readStr: string): string | null {
+  if (!readStr.length) return null;
+
+  let listMatch = RE_LIST_ITEM.exec(readStr);
+  if (isNil(listMatch)) return null;
+
+  const astList: AstBulletList = {
+    kind: AstKind.List,
+    type: AstListType.Bullet,
+    bulletChar: listMatch[1] as ListBulletChar,
+    content: [],
+  };
+  ast.content.push(astList);
+
+  do {
+    const astListItem: AstListItem = {
+      kind: AstKind.ListItem,
+      content: [],
+    };
+    astList.content.push(astListItem);
+
+    readStr = readStr.slice(listMatch[0].length);
+
+    const newReadStr = // parseAtxHeading(astList, readStr) ??
+      // parseList(astList, readStr) ??
+      parseParagraph(astListItem, readStr);
+    if (isNil(newReadStr)) break;
+    readStr = newReadStr;
+
+    listMatch = RE_LIST_ITEM.exec(readStr);
+  } while (!isNil(listMatch));
+
+  return readStr;
+}
+
+function parseParagraph(
+  ast: AstDocument | AstListItem,
+  readStr: string,
+): string | null {
   if (!readStr.length) return null;
 
   const paragraph: AstParagraph = {
@@ -108,7 +170,8 @@ function parseParagraph(ast: AstDocument, readStr: string): string | null {
 
   let paragraphInlineContent = "";
   while (!RE_EMPTY_LINE.test(readStr)) {
-    console.log({ readStr });
+    const listMatch = RE_LIST_ITEM.exec(readStr);
+    if (!isNil(listMatch)) break;
     paragraphInlineContent += readStr.includes("\n")
       ? readStr.slice(0, readStr.indexOf("\n") + 1)
       : readStr;
@@ -161,7 +224,10 @@ function parseText(
   if (!readStr.length) return null;
 
   const parts = readStr.split("\n").filter(Boolean).map(
-    (textPart): AstText => ({ kind: AstKind.Text, content: textPart }),
+    (textPart): AstText => ({
+      kind: AstKind.Text,
+      content: textPart.trimStart(),
+    }),
   );
 
   ast.content.push(...parts);
@@ -172,7 +238,20 @@ function parseText(
 // AST
 
 type AstDocument = BaseAstItem<AstKind.Document, AstDocumentChild[]>;
-type AstDocumentChild = AstAtxHeading | AstParagraph;
+type AstDocumentChild = AstAtxHeading | AstBulletList | AstParagraph | AstList;
+
+type AstList = AstBulletList; // | AstOrderedList
+
+enum AstListType {
+  Bullet,
+  // Ordered,
+}
+
+type ListBulletChar = "-" | "+" | "*";
+
+type AstListItem = BaseAstItem<AstKind.ListItem, AstListItemChild[]>;
+
+type AstListItemChild = AstDocumentChild;
 
 interface AstAtxHeading
   extends BaseAstItem<AstKind.AtxHeading, AstInlineContent[]> {
@@ -181,6 +260,11 @@ interface AstAtxHeading
 
 type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;
 
+interface AstBulletList extends BaseAstItem<AstKind.List, AstListItem[]> {
+  type: AstListType.Bullet;
+  bulletChar: ListBulletChar;
+}
+
 type AstParagraph = BaseAstItem<AstKind.Paragraph, AstInlineContent[]>;
 
 type AstInlineContent = AstText | AstLink;
@@ -201,6 +285,8 @@ enum AstKind {
   Document,
   AtxHeading,
   Paragraph,
+  List,
+  ListItem,
   Link,
   Text,
 }