Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -205,14 +205,38 @@ public static string EscapeLine(string s)

private const string SeeCrefStart = "<see ";
private const string SeeCrefEnd = "</see>";

// Allowed XML documentation tags that should not be escaped
private static readonly HashSet<string> AllowedXmlDocTags = new HashSet<string>(StringComparer.Ordinal)
{
"<see ",
"</see>",
"<b>",
"</b>",
"<i>",
"</i>",
"<list ",
"</list>",
"<item>",
"</item>",
"<description>",
"</description>"
};

private static bool SkipValidTag(ref ReadOnlySpan<char> span, ref int i)
{
var slice = span.Slice(i);
if (slice.StartsWith(SeeCrefStart.AsSpan(), StringComparison.Ordinal) || slice.StartsWith(SeeCrefEnd.AsSpan(), StringComparison.Ordinal))

// Check if the tag starts with any of the allowed XML doc tags
foreach (var tag in AllowedXmlDocTags)
{
i += slice.IndexOf('>');
return true;
if (slice.StartsWith(tag.AsSpan(), StringComparison.Ordinal))
{
i += slice.IndexOf('>');
return true;
}
}

return false;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,161 @@
// Licensed under the MIT License.

using System;
using System.Collections.Generic;
using System.Text;
using System.Text.RegularExpressions;

namespace Microsoft.TypeSpec.Generator.Utilities
{
public class DocHelpers
{
// Pre-compiled regex patterns for better performance
private static readonly Regex NumberedListRegex = new Regex(@"^\d+\.\s", RegexOptions.Compiled);
private static readonly Regex NumberedListCaptureRegex = new Regex(@"^\d+\.\s+(.*)", RegexOptions.Compiled);
private static readonly Regex BoldItalicRegex = new Regex(@"\*\*\*([^*]+?)\*\*\*", RegexOptions.Compiled);
private static readonly Regex BoldRegex = new Regex(@"\*\*([^*]+?)\*\*", RegexOptions.Compiled);
private static readonly Regex ItalicRegex = new Regex(@"(?<!\*)\*([^*]+?)\*(?!\*)", RegexOptions.Compiled);

public static string? GetDescription(string? summary, string? doc)
{
return (summary, doc) switch
var description = (summary, doc) switch
{
(null or "", null or "") => null,
(string s, null or "") => s,
_ => doc,
};

return description != null ? ConvertMarkdownToXml(description) : null;
}

public static FormattableString? GetFormattableDescription(string? summary, string? doc)
{
return FormattableStringHelpers.FromString(GetDescription(summary, doc));
}

/// <summary>
/// Converts markdown syntax to C# XML documentation syntax.
/// Handles bold (**text**), italic (*text*), bullet lists (- item), and numbered lists (1. item).
/// </summary>
internal static string ConvertMarkdownToXml(string markdown)
{
if (string.IsNullOrEmpty(markdown))
return markdown;

var lines = markdown.Split(new[] { "\r\n", "\r", "\n" }, StringSplitOptions.None);
var result = new StringBuilder();
var inList = false;
var listType = "";
var listItems = new List<string>();

for (int i = 0; i < lines.Length; i++)
{
var line = lines[i];
var trimmedLine = line.TrimStart();

// Check for bullet list item
if (trimmedLine.StartsWith("- "))
{
if (!inList || listType != "bullet")
{
// Flush previous list if different type
if (inList)
{
AppendList(result, listType, listItems);
listItems.Clear();
}
inList = true;
listType = "bullet";
}
// Remove the "- " prefix and add to list items
listItems.Add(ConvertInlineMarkdown(trimmedLine.Substring(2)));
}
// Check for numbered list item (e.g., "1. ", "2. ")
else if (NumberedListRegex.IsMatch(trimmedLine))
{
if (!inList || listType != "number")
{
// Flush previous list if different type
if (inList)
{
AppendList(result, listType, listItems);
listItems.Clear();
}
inList = true;
listType = "number";
}
// Remove the number prefix and add to list items
var match = NumberedListCaptureRegex.Match(trimmedLine);
listItems.Add(ConvertInlineMarkdown(match.Groups[1].Value));
}
else
{
// Not a list item, flush any pending list
if (inList)
{
AppendList(result, listType, listItems);
listItems.Clear();
inList = false;
}

// Process inline markdown (bold, italic) for regular lines
var processedLine = ConvertInlineMarkdown(line);

// Add line to result
if (result.Length > 0 && !string.IsNullOrWhiteSpace(processedLine))
{
result.AppendLine();
}
result.Append(processedLine);
}
}

// Flush any remaining list
if (inList)
{
AppendList(result, listType, listItems);
}

return result.ToString();
}

private static void AppendList(StringBuilder result, string listType, List<string> items)
{
if (items.Count == 0)
return;

if (result.Length > 0)
{
result.AppendLine();
}

result.Append($"<list type=\"{listType}\">");
foreach (var item in items)
{
result.Append($"<item><description>{item}</description></item>");
}
result.Append("</list>");
}

/// <summary>
/// Converts inline markdown (bold and italic) to XML tags.
/// Handles: **bold**, ***bold italic***, *italic*
/// </summary>
private static string ConvertInlineMarkdown(string text)
{
if (string.IsNullOrEmpty(text))
return text;

// Handle ***bold italic*** (must be done before ** and *)
text = BoldItalicRegex.Replace(text, "<b><i>$1</i></b>");

// Handle **bold**
text = BoldRegex.Replace(text, "<b>$1</b>");

// Handle *italic* (but not already processed bold markers)
text = ItalicRegex.Replace(text, "<i>$1</i>");

return text;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

using Microsoft.TypeSpec.Generator.Utilities;
using NUnit.Framework;

namespace Microsoft.TypeSpec.Generator.Tests.Utilities
{
public class DocHelpersTests
{
[Test]
public void TestBoldText()
{
var result = DocHelpers.GetDescription(null, "This is **bold text** in the middle.");
Assert.AreEqual("This is <b>bold text</b> in the middle.", result);
}

[Test]
public void TestMultipleBold()
{
var result = DocHelpers.GetDescription(null, "This has **multiple bold** sections and **another bold** section.");
Assert.AreEqual("This has <b>multiple bold</b> sections and <b>another bold</b> section.", result);
}

[Test]
public void TestItalicText()
{
var result = DocHelpers.GetDescription(null, "This is *italic text* in the middle.");
Assert.AreEqual("This is <i>italic text</i> in the middle.", result);
}

[Test]
public void TestMultipleItalic()
{
var result = DocHelpers.GetDescription(null, "This has *multiple italic* sections and *another italic* section.");
Assert.AreEqual("This has <i>multiple italic</i> sections and <i>another italic</i> section.", result);
}

[Test]
public void TestBoldItalicCombined()
{
var result = DocHelpers.GetDescription(null, "This has **bold**, *italic*, and ***bold italic*** text.");
Assert.AreEqual("This has <b>bold</b>, <i>italic</i>, and <b><i>bold italic</i></b> text.", result);
}

[Test]
public void TestNestedFormatting()
{
var result = DocHelpers.GetDescription(null, "You can combine them like **bold with *italic inside* bold**.");
// This is a complex case - the current implementation will handle the outermost first
// The expected behavior should convert ** first, then * inside
Assert.IsNotNull(result);
Assert.That(result, Does.Contain("<b>").Or.Contain("<i>"));
}

[Test]
public void TestBulletList()
{
var markdown = @"This tests:
- First bullet point
- Second bullet point
- Third bullet point";
var result = DocHelpers.GetDescription(null, markdown);

Assert.That(result, Does.Contain("<list type=\"bullet\">"));
Assert.That(result, Does.Contain("<item><description>First bullet point</description></item>"));
Assert.That(result, Does.Contain("<item><description>Second bullet point</description></item>"));
Assert.That(result, Does.Contain("<item><description>Third bullet point</description></item>"));
Assert.That(result, Does.Contain("</list>"));
}

[Test]
public void TestBulletListWithFormatting()
{
var markdown = @"This tests:
- Simple bullet point
- Bullet with **bold text**
- Bullet with *italic text*";
var result = DocHelpers.GetDescription(null, markdown);

Assert.That(result, Does.Contain("<list type=\"bullet\">"));
Assert.That(result, Does.Contain("<item><description>Simple bullet point</description></item>"));
Assert.That(result, Does.Contain("<item><description>Bullet with <b>bold text</b></description></item>"));
Assert.That(result, Does.Contain("<item><description>Bullet with <i>italic text</i></description></item>"));
}

[Test]
public void TestNumberedList()
{
var markdown = @"Steps to follow:
1. First step
2. Second step
3. Third step";
var result = DocHelpers.GetDescription(null, markdown);

Assert.That(result, Does.Contain("<list type=\"number\">"));
Assert.That(result, Does.Contain("<item><description>First step</description></item>"));
Assert.That(result, Does.Contain("<item><description>Second step</description></item>"));
Assert.That(result, Does.Contain("<item><description>Third step</description></item>"));
Assert.That(result, Does.Contain("</list>"));
}

[Test]
public void TestNumberedListWithFormatting()
{
var markdown = @"Steps:
1. First step with **important** note
2. Second step with *emphasis*
3. Third step combining **bold** and *italic*";
var result = DocHelpers.GetDescription(null, markdown);

Assert.That(result, Does.Contain("<list type=\"number\">"));
Assert.That(result, Does.Contain("<item><description>First step with <b>important</b> note</description></item>"));
Assert.That(result, Does.Contain("<item><description>Second step with <i>emphasis</i></description></item>"));
}

[Test]
public void TestMixedContent()
{
var markdown = @"This is a paragraph with **bold** text.
- First bullet
- Second bullet
Another paragraph with *italic* text.";
var result = DocHelpers.GetDescription(null, markdown);

Assert.That(result, Does.Contain("<b>bold</b>"));
Assert.That(result, Does.Contain("<list type=\"bullet\">"));
Assert.That(result, Does.Contain("<i>italic</i>"));
}

[Test]
public void TestEmptyString()
{
var result = DocHelpers.GetDescription(null, "");
Assert.IsNull(result);
}

[Test]
public void TestNullString()
{
var result = DocHelpers.GetDescription(null, null);
Assert.IsNull(result);
}

[Test]
public void TestPlainText()
{
var result = DocHelpers.GetDescription(null, "This is plain text without any markdown.");
Assert.AreEqual("This is plain text without any markdown.", result);
}

[Test]
public void TestSummaryPreferredOverDoc()
{
var result = DocHelpers.GetDescription("Summary text", "Doc text");
Assert.AreEqual("Doc text", result);
}

[Test]
public void TestSummaryUsedWhenDocEmpty()
{
var result = DocHelpers.GetDescription("Summary with **bold**", "");
Assert.AreEqual("Summary with <b>bold</b>", result);
}
}
}
Loading
Loading