diff --git a/src/common/gitUtils.ts b/src/common/gitUtils.ts index dc4539e1ec..766939a0d7 100644 --- a/src/common/gitUtils.ts +++ b/src/common/gitUtils.ts @@ -7,6 +7,350 @@ import * as vscode from 'vscode'; import { Repository } from '../api/api'; import { GitApiImpl } from '../api/api1'; +/** + * Unwraps lines that were wrapped for conventional commit message formatting (typically at 72 characters). + * Similar to GitHub's behavior when converting commit messages to PR descriptions. + * + * Rules: + * - Preserves blank lines as paragraph breaks + * - Preserves fenced code blocks (```) + * - Preserves list items (-, *, +, numbered) + * - Preserves blockquotes (>) + * - Preserves indented code blocks (4+ spaces at start, when not in a list context) + * - Joins consecutive plain text lines that appear to be wrapped mid-sentence + */ +export function unwrapCommitMessageBody(body: string): string { + if (!body) { + return body; + } + + // Pattern to detect list item markers at the start of a line and capture the marker + const LIST_ITEM_PATTERN = /^(?[ \t]*)(?[*+\-]|\d+\.)(?[ \t]+)/; + // Pattern to detect blockquote markers + const BLOCKQUOTE_PATTERN = /^[ \t]*>/; + // Pattern to detect fenced code block markers + const FENCE_PATTERN = /^[ \t]*```/; + + const getLeadingWhitespaceLength = (text: string): number => text.match(/^[ \t]*/)?.[0].length ?? 0; + const hasHardLineBreak = (text: string): boolean => / {2}$/.test(text); + const appendWithSpace = (base: string, addition: string): string => { + if (!addition) { + return base; + } + return base.length > 0 && !/\s$/.test(base) ? `${base} ${addition}` : `${base}${addition}`; + }; + + // Get the content indent for a list item (position where actual content starts) + const getListItemContentIndent = (line: string): number => { + const match = line.match(LIST_ITEM_PATTERN); + if (!match?.groups) { + return 0; + } + // Content indent = leading whitespace + marker + space after marker + return match.groups.leadingWhitespace.length + match.groups.marker.length + match.groups.markerTrailingWhitespace.length; + }; + + const lines = body.split('\n'); + const result: string[] = []; + let i = 0; + let inFencedBlock = false; + // Stack stores { markerIndent, contentIndent } for each nesting level + const listStack: { markerIndent: number; contentIndent: number }[] = []; + + // Find the active list context for a given line indent + // Returns the content indent if the line is within an active list context + const getActiveListContentIndent = (lineIndent: number): number | undefined => { + for (let idx = listStack.length - 1; idx >= 0; idx--) { + const { markerIndent, contentIndent } = listStack[idx]; + // A line is part of a list item if it has at least 1 space indent + // (but less than contentIndent + 4 which would be a code block) + if (lineIndent >= 1 && lineIndent >= markerIndent) { + listStack.length = idx + 1; + return contentIndent; + } + listStack.pop(); + } + return undefined; + }; + + const shouldJoinListContinuation = (lineIndex: number, contentIndent: number, baseLine: string): boolean => { + const currentLine = lines[lineIndex]; + if (!currentLine) { + return false; + } + + const trimmed = currentLine.trim(); + if (!trimmed) { + return false; + } + + if (hasHardLineBreak(baseLine) || hasHardLineBreak(currentLine)) { + return false; + } + + if (LIST_ITEM_PATTERN.test(currentLine)) { + return false; + } + + if (BLOCKQUOTE_PATTERN.test(currentLine) || FENCE_PATTERN.test(currentLine)) { + return false; + } + + const currentIndent = getLeadingWhitespaceLength(currentLine); + // Need at least 1 space to be a continuation + if (currentIndent < 1) { + return false; + } + + // 4+ spaces beyond content indent is an indented code block + if (currentIndent >= contentIndent + 4) { + return false; + } + + return true; + }; + + while (i < lines.length) { + const line = lines[i]; + + // Preserve blank lines but don't clear list context + // (multi-paragraph lists are allowed in GitHub markdown) + if (line.trim() === '') { + result.push(line); + i++; + continue; + } + + // Check for fenced code block markers + if (FENCE_PATTERN.test(line)) { + inFencedBlock = !inFencedBlock; + result.push(line); + i++; + continue; + } + + // Preserve everything inside fenced code blocks + if (inFencedBlock) { + result.push(line); + i++; + continue; + } + + const lineIndent = getLeadingWhitespaceLength(line); + const listItemMatch = line.match(LIST_ITEM_PATTERN); + + if (listItemMatch?.groups) { + const markerIndent = listItemMatch.groups.leadingWhitespace.length; + const contentIndent = getListItemContentIndent(line); + + // Pop list levels that are at or beyond this indent + while (listStack.length && markerIndent <= listStack[listStack.length - 1].markerIndent) { + listStack.pop(); + } + + listStack.push({ markerIndent, contentIndent }); + result.push(line); + i++; + continue; + } + + // Handle non-indented lines that should be joined to a previous list item + // This happens when commit messages are wrapped at 72 characters + // Check this BEFORE calling getActiveListContentIndent which would clear the stack + if (listStack.length > 0 && lineIndent === 0 && !LIST_ITEM_PATTERN.test(line)) { + const isBlockquote = BLOCKQUOTE_PATTERN.test(line); + if (!isBlockquote) { + const baseIndex = result.length - 1; + const baseLine = baseIndex >= 0 ? result[baseIndex] : ''; + const previousLineIsBlank = baseLine.trim() === ''; + + if (!previousLineIsBlank && baseIndex >= 0) { + // Join this line and any following non-list-item lines with the previous list item + let joinedLine = baseLine; + let currentIndex = i; + + while (currentIndex < lines.length) { + const currentLine = lines[currentIndex]; + const trimmed = currentLine.trim(); + + // Stop at blank lines + if (!trimmed) { + break; + } + + // Stop at list items + if (LIST_ITEM_PATTERN.test(currentLine)) { + break; + } + + // Stop at blockquotes or fences + if (BLOCKQUOTE_PATTERN.test(currentLine) || FENCE_PATTERN.test(currentLine)) { + break; + } + + // Stop at indented code blocks + const currentLineIndent = getLeadingWhitespaceLength(currentLine); + if (currentLineIndent >= 4) { + break; + } + + // Stop if previous line has hard line break + if (hasHardLineBreak(joinedLine)) { + break; + } + + joinedLine = appendWithSpace(joinedLine, trimmed); + currentIndex++; + } + + if (currentIndex > i) { + result[baseIndex] = joinedLine; + i = currentIndex; + continue; + } + } + } + } + + const activeContentIndent = getActiveListContentIndent(lineIndent); + const codeIndentThreshold = activeContentIndent !== undefined ? activeContentIndent + 4 : 4; + const isBlockquote = BLOCKQUOTE_PATTERN.test(line); + const isIndentedCode = lineIndent >= codeIndentThreshold; + + if (isBlockquote || isIndentedCode) { + result.push(line); + i++; + continue; + } + + // Handle list item continuations + if (activeContentIndent !== undefined && lineIndent >= 1) { + const baseIndex = result.length - 1; + // Only try to join with previous line if it's not blank + // Multi-paragraph lists have blank lines that should be preserved + const baseLine = baseIndex >= 0 ? result[baseIndex] : ''; + const previousLineIsBlank = baseLine.trim() === ''; + + if (!previousLineIsBlank && baseIndex >= 0) { + let joinedLine = baseLine; + let appended = false; + let currentIndex = i; + + while ( + currentIndex < lines.length && + shouldJoinListContinuation(currentIndex, activeContentIndent, joinedLine) + ) { + const continuationText = lines[currentIndex].trim(); + if (continuationText) { + joinedLine = appendWithSpace(joinedLine, continuationText); + appended = true; + } + currentIndex++; + } + + if (appended) { + result[baseIndex] = joinedLine; + i = currentIndex; + continue; + } + } + + // For multi-paragraph continuations or standalone indented lines, + // preserve indentation but unwrap consecutive continuation lines + let joinedLine = line; + i++; + + while (i < lines.length) { + const nextLine = lines[i]; + + if (nextLine.trim() === '') { + break; + } + + if (FENCE_PATTERN.test(nextLine)) { + break; + } + + if (LIST_ITEM_PATTERN.test(nextLine)) { + break; + } + + if (BLOCKQUOTE_PATTERN.test(nextLine)) { + break; + } + + const nextIndent = getLeadingWhitespaceLength(nextLine); + // Check for code block + if (nextIndent >= activeContentIndent + 4) { + break; + } + + // Must have at least 1 space to be a continuation + if (nextIndent < 1) { + break; + } + + // Check for hard line break + if (hasHardLineBreak(joinedLine)) { + break; + } + + // Join this line - preserve the original indentation for the first line + joinedLine = appendWithSpace(joinedLine, nextLine.trim()); + i++; + } + + result.push(joinedLine); + continue; + } + + // Start accumulating lines that should be joined (plain text) + let joinedLine = line; + i++; + + // Keep joining lines until we hit a blank line or a line that shouldn't be joined + while (i < lines.length) { + const nextLine = lines[i]; + + // Stop at blank lines + if (nextLine.trim() === '') { + break; + } + + // Stop at fenced code blocks + if (FENCE_PATTERN.test(nextLine)) { + break; + } + + // Stop at list items + if (LIST_ITEM_PATTERN.test(nextLine)) { + break; + } + + // Stop at blockquotes + if (BLOCKQUOTE_PATTERN.test(nextLine)) { + break; + } + + // Check if next line is indented code (4+ spaces, when not in a list context) + const nextLeadingSpaces = getLeadingWhitespaceLength(nextLine); + const nextIsIndentedCode = nextLeadingSpaces >= 4; + + if (nextIsIndentedCode) { + break; + } + + // Join this line with a space + joinedLine = appendWithSpace(joinedLine, nextLine.trim()); + i++; + } + + result.push(joinedLine); + } + + return result.join('\n'); +} + /** * Determines if a repository is a submodule by checking if its path * appears in any other repository's submodules list. diff --git a/src/github/folderRepositoryManager.ts b/src/github/folderRepositoryManager.ts index b62f22994b..31b70fc892 100644 --- a/src/github/folderRepositoryManager.ts +++ b/src/github/folderRepositoryManager.ts @@ -34,6 +34,7 @@ import { AuthProvider, GitHubServerType } from '../common/authentication'; import { commands, contexts } from '../common/executeCommands'; import { InMemFileChange, SlimFileChange } from '../common/file'; import { findLocalRepoRemoteFromGitHubRef } from '../common/githubRef'; +import { unwrapCommitMessageBody } from '../common/gitUtils'; import { Disposable, disposeAll } from '../common/lifecycle'; import Logger from '../common/logger'; import { Protocol, ProtocolType } from '../common/protocol'; @@ -3074,253 +3075,6 @@ const ownedByMe: AsyncPredicate = async repo => { export const byRemoteName = (name: string): Predicate => ({ remote: { remoteName } }) => remoteName === name; -/** - * Unwraps lines that were wrapped for conventional commit message formatting (typically at 72 characters). - * Similar to GitHub's behavior when converting commit messages to PR descriptions. - * - * Rules: - * - Preserves blank lines as paragraph breaks - * - Preserves fenced code blocks (```) - * - Preserves list items (-, *, +, numbered) - * - Preserves blockquotes (>) - * - Preserves indented code blocks (4+ spaces at start, when not in a list context) - * - Joins consecutive plain text lines that appear to be wrapped mid-sentence - */ -function unwrapCommitMessageBody(body: string): string { - if (!body) { - return body; - } - - // Pattern to detect list item markers at the start of a line - const LIST_ITEM_PATTERN = /^[ \t]*([*+\-]|\d+\.)\s/; - // Pattern to detect blockquote markers - const BLOCKQUOTE_PATTERN = /^[ \t]*>/; - // Pattern to detect fenced code block markers - const FENCE_PATTERN = /^[ \t]*```/; - - const getLeadingWhitespaceLength = (text: string): number => text.match(/^[ \t]*/)?.[0].length ?? 0; - const hasHardLineBreak = (text: string): boolean => / {2}$/.test(text); - const appendWithSpace = (base: string, addition: string): string => { - if (!addition) { - return base; - } - return base.length > 0 && !/\s$/.test(base) ? `${base} ${addition}` : `${base}${addition}`; - }; - - const lines = body.split('\n'); - const result: string[] = []; - let i = 0; - let inFencedBlock = false; - const listIndentStack: number[] = []; - - const getNextNonBlankLineInfo = ( - startIndex: number, - ): { line: string; indent: number; isListItem: boolean } | undefined => { - for (let idx = startIndex; idx < lines.length; idx++) { - const candidate = lines[idx]; - if (candidate.trim() === '') { - continue; - } - return { - line: candidate, - indent: getLeadingWhitespaceLength(candidate), - isListItem: LIST_ITEM_PATTERN.test(candidate), - }; - } - return undefined; - }; - - const getActiveListIndent = (lineIndent: number): number | undefined => { - for (let idx = listIndentStack.length - 1; idx >= 0; idx--) { - const indentForLevel = listIndentStack[idx]; - if (lineIndent >= indentForLevel + 2) { - listIndentStack.length = idx + 1; - return indentForLevel; - } - listIndentStack.pop(); - } - return undefined; - }; - - const shouldJoinListContinuation = (lineIndex: number, activeIndent: number, baseLine: string): boolean => { - const currentLine = lines[lineIndex]; - if (!currentLine) { - return false; - } - - const trimmed = currentLine.trim(); - if (!trimmed) { - return false; - } - - if (hasHardLineBreak(baseLine) || hasHardLineBreak(currentLine)) { - return false; - } - - if (LIST_ITEM_PATTERN.test(currentLine)) { - return false; - } - - if (BLOCKQUOTE_PATTERN.test(currentLine) || FENCE_PATTERN.test(currentLine)) { - return false; - } - - const currentIndent = getLeadingWhitespaceLength(currentLine); - if (currentIndent < activeIndent + 2) { - return false; - } - - // Treat indented code blocks (4+ spaces beyond the bullet) as preserve-only. - if (currentIndent >= activeIndent + 4) { - return false; - } - - const nextInfo = getNextNonBlankLineInfo(lineIndex + 1); - if (!nextInfo) { - return true; - } - - if (nextInfo.isListItem && nextInfo.indent <= activeIndent) { - return false; - } - - return true; - }; - - while (i < lines.length) { - const line = lines[i]; - - // Preserve blank lines - if (line.trim() === '') { - result.push(line); - i++; - listIndentStack.length = 0; - continue; - } - - // Check for fenced code block markers - if (FENCE_PATTERN.test(line)) { - inFencedBlock = !inFencedBlock; - result.push(line); - i++; - continue; - } - - // Preserve everything inside fenced code blocks - if (inFencedBlock) { - result.push(line); - i++; - continue; - } - - const lineIndent = getLeadingWhitespaceLength(line); - const isListItem = LIST_ITEM_PATTERN.test(line); - - if (isListItem) { - while (listIndentStack.length && lineIndent < listIndentStack[listIndentStack.length - 1]) { - listIndentStack.pop(); - } - - if (!listIndentStack.length || lineIndent > listIndentStack[listIndentStack.length - 1]) { - listIndentStack.push(lineIndent); - } else { - listIndentStack[listIndentStack.length - 1] = lineIndent; - } - - result.push(line); - i++; - continue; - } - - const activeListIndent = getActiveListIndent(lineIndent); - const codeIndentThreshold = activeListIndent !== undefined ? activeListIndent + 4 : 4; - const isBlockquote = BLOCKQUOTE_PATTERN.test(line); - const isIndentedCode = lineIndent >= codeIndentThreshold; - - if (isBlockquote || isIndentedCode) { - result.push(line); - i++; - continue; - } - - if (activeListIndent !== undefined && lineIndent >= activeListIndent + 2) { - const baseIndex = result.length - 1; - if (baseIndex >= 0) { - let baseLine = result[baseIndex]; - let appended = false; - let currentIndex = i; - - while ( - currentIndex < lines.length && - shouldJoinListContinuation(currentIndex, activeListIndent, baseLine) - ) { - const continuationText = lines[currentIndex].trim(); - if (continuationText) { - baseLine = appendWithSpace(baseLine, continuationText); - appended = true; - } - currentIndex++; - } - - if (appended) { - result[baseIndex] = baseLine; - i = currentIndex; - continue; - } - } - - result.push(line); - i++; - continue; - } - - // Start accumulating lines that should be joined (plain text) - let joinedLine = line; - i++; - - // Keep joining lines until we hit a blank line or a line that shouldn't be joined - while (i < lines.length) { - const nextLine = lines[i]; - - // Stop at blank lines - if (nextLine.trim() === '') { - break; - } - - // Stop at fenced code blocks - if (FENCE_PATTERN.test(nextLine)) { - break; - } - - // Stop at list items - if (LIST_ITEM_PATTERN.test(nextLine)) { - break; - } - - // Stop at blockquotes - if (BLOCKQUOTE_PATTERN.test(nextLine)) { - break; - } - - // Check if next line is indented code (4+ spaces, when not in a list context) - const nextLeadingSpaces = getLeadingWhitespaceLength(nextLine); - const nextIsIndentedCode = nextLeadingSpaces >= 4; - - if (nextIsIndentedCode) { - break; - } - - // Join this line with a space - joinedLine += ' ' + nextLine; - i++; - } - - result.push(joinedLine); - } - - return result.join('\n'); -} - export const titleAndBodyFrom = async (promise: Promise): Promise<{ title: string; body: string } | undefined> => { const message = await promise; if (!message) { diff --git a/src/test/github/folderRepositoryManager.test.ts b/src/test/github/folderRepositoryManager.test.ts index 6d6f6c3114..4a43d8ad15 100644 --- a/src/test/github/folderRepositoryManager.test.ts +++ b/src/test/github/folderRepositoryManager.test.ts @@ -191,12 +191,12 @@ describe('titleAndBodyFrom', function () { assert.strictEqual(result?.body, '- Item 1\n - Nested item 1.1\n - Nested item 1.2\n- Item 2'); }); - it('preserves list item continuations', async function () { + it('unwraps list item continuations', async function () { const message = Promise.resolve('title\n\n- This is a list item that is long\n and continues on the next line\n- Second item'); const result = await titleAndBodyFrom(message); assert.strictEqual(result?.title, 'title'); - assert.strictEqual(result?.body, '- This is a list item that is long\n and continues on the next line\n- Second item'); + assert.strictEqual(result?.body, '- This is a list item that is long and continues on the next line\n- Second item'); }); it('preserves indented code blocks but not list continuations', async function () { @@ -207,12 +207,12 @@ describe('titleAndBodyFrom', function () { assert.strictEqual(result?.body, 'Regular paragraph.\n\n This is code\n More code\n\nAnother paragraph.'); }); - it('unwraps regular text but preserves list item continuations', async function () { + it('unwraps regular text and list item continuations', async function () { const message = Promise.resolve('title\n\nThis is wrapped text\nthat should be joined.\n\n- List item with\n continuation\n- Another item'); const result = await titleAndBodyFrom(message); assert.strictEqual(result?.title, 'title'); - assert.strictEqual(result?.body, 'This is wrapped text that should be joined.\n\n- List item with\n continuation\n- Another item'); + assert.strictEqual(result?.body, 'This is wrapped text that should be joined.\n\n- List item with continuation\n- Another item'); }); it('handles complex nested lists with wrapped paragraphs', async function () { @@ -220,7 +220,7 @@ describe('titleAndBodyFrom', function () { const result = await titleAndBodyFrom(message); assert.strictEqual(result?.title, 'title'); - assert.strictEqual(result?.body, 'Wrapped paragraph across lines.\n\n- Item 1\n - Nested item\n More nested content\n- Item 2\n\nAnother wrapped paragraph here.'); + assert.strictEqual(result?.body, 'Wrapped paragraph across lines.\n\n- Item 1\n - Nested item More nested content\n- Item 2\n\nAnother wrapped paragraph here.'); }); it('handles nested lists', async function () { @@ -230,4 +230,116 @@ describe('titleAndBodyFrom', function () { assert.strictEqual(result?.title, 'title'); assert.strictEqual(result?.body, '* This is a list item with two lines that have a line break between them\n * This is a nested list item that also has two lines that should have been merged'); }); + + it('handles basic numeric list continuation', async function () { + const message = Promise.resolve('title\n\n1. Basic numeric list\n continuation.\n Third line'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, '1. Basic numeric list continuation. Third line'); + }); + + it('handles additional spaces OK for continuation', async function () { + const message = Promise.resolve('title\n\n2. Additional spaces are\n OK for a continuation (unless it\'s 4 spaces which would be a code block).\n Third line'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, '2. Additional spaces are OK for a continuation (unless it\'s 4 spaces which would be a code block). Third line'); + }); + + it('handles asterisk list with extra spaces', async function () { + const message = Promise.resolve('title\n\n* Additional spaces are\n OK for a continuation (unless it\'s 4 spaces which would be a code block).\n Third line'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, '* Additional spaces are OK for a continuation (unless it\'s 4 spaces which would be a code block). Third line'); + }); + + it('handles multi-digit numbers (10.)', async function () { + const message = Promise.resolve('title\n\n10. Multi-digit numbers should also\n work for a continuation.\n Third line'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, '10. Multi-digit numbers should also work for a continuation. Third line'); + }); + + it('handles multi-paragraph list - numbered', async function () { + const message = Promise.resolve('title\n\n11. Multi-paragraph lists are also supported.\n\n Second paragraph in the same list item.\n Third line'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, '11. Multi-paragraph lists are also supported.\n\n Second paragraph in the same list item. Third line'); + }); + + it('handles multi-paragraph list - asterisk', async function () { + const message = Promise.resolve('title\n\n* Multi-paragraph lists are also supported.\n\n Second paragraph in the same list item.\n Third line'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, '* Multi-paragraph lists are also supported.\n\n Second paragraph in the same list item. Third line'); + }); + + it('handles item with code block - numbered', async function () { + const message = Promise.resolve('title\n\n1. Item with code:\n\n ```\n code line\n code line\n ```'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, '1. Item with code:\n\n ```\n code line\n code line\n ```'); + }); + + it('handles item with code block - asterisk', async function () { + const message = Promise.resolve('title\n\n* Item with code:\n\n ```\n code line\n code line\n ```'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, '* Item with code:\n\n ```\n code line\n code line\n ```'); + }); + + it('handles fewer spaces OK - numbered (1 space)', async function () { + const message = Promise.resolve('title\n\n1. Fewer spaces are also OK\n for a list continuation (as long as there\'s at least one space)'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, '1. Fewer spaces are also OK for a list continuation (as long as there\'s at least one space)'); + }); + + it('handles fewer spaces OK - asterisk (1 space)', async function () { + const message = Promise.resolve('title\n\n* Fewer spaces are also OK\n for a list continuation (as long as there\'s at least one space)'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, '* Fewer spaces are also OK for a list continuation (as long as there\'s at least one space)'); + }); + + it('handles nested numbered lists', async function () { + const message = Promise.resolve('title\n\n1. First level item\n continuation of first level\n 1. Nested numbered item\n with continuation'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, '1. First level item continuation of first level\n 1. Nested numbered item with continuation'); + }); + + it('handles nested multi-digit numbered lists', async function () { + const message = Promise.resolve('title\n\n10. First level item with\n multi-line content\n 10. Nested with multi-digit\n number and continuation'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, '10. First level item with multi-line content\n 10. Nested with multi-digit number and continuation'); + }); + + it('handles nested multi-paragraph lists', async function () { + const message = Promise.resolve('title\n\n* Outer item\n\n Second paragraph of outer\n with continuation\n * Inner item\n\n Second paragraph of inner\n with continuation'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, '* Outer item\n\n Second paragraph of outer with continuation\n * Inner item\n\n Second paragraph of inner with continuation'); + }); + + it('handles first list item needs to be unwrapped', async function () { + const message = Promise.resolve('This is a test\n\n- A fslilenfilnf flen felslnf lsefl fnels Leknef\nLkdfnle lfkenSlefn Lnkef LefnLienf LIfnels\n- B\n- C'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'This is a test'); + assert.strictEqual(result?.body, '- A fslilenfilnf flen felslnf lsefl fnels Leknef Lkdfnle lfkenSlefn Lnkef LefnLienf LIfnels\n- B\n- C'); + }); });