#nullable enable namespace T3.Editor.Gui.Styling.Markdown; /// /// Parses a small fixed markdown subset into a logical-line + inline-run representation. /// Runs reference the owning source string by (start, length) slices — no substring allocation. /// internal static class MarkdownParser { public static void Parse(string source, ParsedDoc doc, bool hardLineBreaks = false) { // Soft line breaks: in markdown a single newline between two // paragraph-shaped lines means "join with a space", not "force line // break". CommonMark-style. Special lines (headings, list items, // blanks) keep their hard breaks. Callers that want every `\n` to be // a hard break (e.g. legacy TourPoint content) can pass true. var normalized = hardLineBreaks ? source.Replace("\r\n", "\n") : NormalizeSoftLineBreaks(source); doc.Reset(normalized); var i = 0; var len = normalized.Length; while (i < len) { // Find end of logical line (terminator excluded). var lineStart = i; while (i < len && normalized[i] != '\n') i++; var lineEnd = i; // Normalisation already collapsed \r\n → \n, so no CR cleanup needed. ParseLine(normalized, lineStart, lineEnd, doc); // Skip the \n. if (i < len && normalized[i] == '\n') i++; } } private static string NormalizeSoftLineBreaks(string source) { var raw = source.Replace("\r\n", "\n"); var lines = raw.Split('\n'); var sb = new System.Text.StringBuilder(raw.Length); var prevKind = SoftLineKind.None; for (var i = 0; i < lines.Length; i++) { var line = lines[i]; var kind = ClassifySoftLine(line, out var hasLeadingWhitespace); // Two folding cases: // 1. Paragraph after Paragraph — classic CommonMark soft line break. // 2. Indented Paragraph after a list item — "lazy continuation" // so the item's content keeps reading as one wrapped run. // Anything else (blank, heading, new list item) keeps the hard \n. var foldIntoListItem = kind == SoftLineKind.Paragraph && prevKind == SoftLineKind.ListItem && hasLeadingWhitespace; var foldIntoParagraph = kind == SoftLineKind.Paragraph && prevKind == SoftLineKind.Paragraph; var fold = foldIntoListItem || foldIntoParagraph; if (sb.Length > 0) sb.Append(fold ? ' ' : '\n'); // Trim leading whitespace from a folded line so the join doesn't // produce "foo bar". sb.Append(fold ? line.TrimStart() : line); // When we fold a continuation into a list item, keep prevKind as // ListItem so subsequent indented lines also fold instead of // detaching after the first. if (!foldIntoListItem) prevKind = kind; } return sb.ToString(); } private static SoftLineKind ClassifySoftLine(string line, out bool hasLeadingWhitespace) { hasLeadingWhitespace = line.Length > 0 && (line[0] == ' ' || line[0] == '\t'); var trimmed = line.TrimStart(); if (trimmed.Length == 0) return SoftLineKind.Blank; if (trimmed[0] == '#') return SoftLineKind.Heading; if ((trimmed[0] == '-' || trimmed[0] == '*') && trimmed.Length > 1 && trimmed[1] == ' ') return SoftLineKind.ListItem; if (char.IsDigit(trimmed[0])) { var i = 0; while (i < trimmed.Length && char.IsDigit(trimmed[i])) i++; if (i + 1 < trimmed.Length && trimmed[i] == '.' && trimmed[i + 1] == ' ') return SoftLineKind.ListItem; } return SoftLineKind.Paragraph; } private enum SoftLineKind { None, Blank, Paragraph, Heading, ListItem, } private static void ParseLine(string src, int from, int to, ParsedDoc doc) { // Indent: count leading spaces (tab = 2 spaces). Depth = indent / 2. var indentSpaces = 0; var p = from; while (p < to) { var c = src[p]; if (c == ' ') indentSpaces++; else if (c == '\t') indentSpaces += 2; else break; p++; } // Blank line. if (p == to) { doc.Lines.Add(new Line { Kind = LineKind.Blank, IndentLevel = 0, RunStart = doc.Runs.Count, RunCount = 0, }); return; } var depth = (byte)(indentSpaces / 2); // Heading. var hashCount = 0; var hp = p; while (hp < to && src[hp] == '#' && hashCount < 3) { hashCount++; hp++; } if (hashCount > 0 && hp < to && src[hp] == ' ') { var contentStart = hp + 1; var kind = hashCount switch { 1 => LineKind.H1, 2 => LineKind.H2, _ => LineKind.H3, }; var runStart = doc.Runs.Count; ParseInline(src, contentStart, to, doc); doc.Lines.Add(new Line { Kind = kind, IndentLevel = 0, RunStart = runStart, RunCount = doc.Runs.Count - runStart, }); return; } // Bullet list (- or *). if ((src[p] == '-' || src[p] == '*') && p + 1 < to && src[p + 1] == ' ') { var contentStart = p + 2; var runStart = doc.Runs.Count; ParseInline(src, contentStart, to, doc); doc.Lines.Add(new Line { Kind = LineKind.Bullet, IndentLevel = depth, RunStart = runStart, RunCount = doc.Runs.Count - runStart, }); return; } // Numbered list (digits followed by ". "). if (char.IsDigit(src[p])) { var np = p; var num = 0; while (np < to && char.IsDigit(src[np])) { num = num * 10 + (src[np] - '0'); np++; } if (np < to - 1 && src[np] == '.' && src[np + 1] == ' ') { var contentStart = np + 2; var runStart = doc.Runs.Count; ParseInline(src, contentStart, to, doc); doc.Lines.Add(new Line { Kind = LineKind.Numbered, IndentLevel = depth, Number = num, RunStart = runStart, RunCount = doc.Runs.Count - runStart, }); return; } } // Plain paragraph line. Indent only meaningful for lists; we discard it // here (lazy continuation is out of scope per the plan). { var runStart = doc.Runs.Count; ParseInline(src, p, to, doc); doc.Lines.Add(new Line { Kind = LineKind.Paragraph, IndentLevel = 0, RunStart = runStart, RunCount = doc.Runs.Count - runStart, }); } } private static void ParseInline(string src, int from, int to, ParsedDoc doc) { var i = from; var plainStart = from; while (i < to) { var c = src[i]; // **bold** if (c == '*' && i + 1 < to && src[i + 1] == '*') { var close = IndexOfDouble(src, i + 2, to, '*'); if (close >= 0) { FlushPlain(src, plainStart, i, doc); doc.Runs.Add(new Run(new Slice(i + 2, close - (i + 2)), RunStyle.Bold, -1)); i = close + 2; plainStart = i; continue; } } // `code` if (c == '`') { var close = IndexOf(src, i + 1, to, '`'); if (close >= 0) { FlushPlain(src, plainStart, i, doc); doc.Runs.Add(new Run(new Slice(i + 1, close - (i + 1)), RunStyle.Code, -1)); i = close + 1; plainStart = i; continue; } } // [label](url) or [OpName] if (c == '[') { var close = IndexOf(src, i + 1, to, ']'); if (close >= 0) { var labelStart = i + 1; var labelLen = close - labelStart; // Link: [label](url) if (close + 1 < to && src[close + 1] == '(') { var urlClose = IndexOf(src, close + 2, to, ')'); if (urlClose >= 0) { FlushPlain(src, plainStart, i, doc); var urlSlice = new Slice(close + 2, urlClose - (close + 2)); var urlIndex = doc.Urls.Count; doc.Urls.Add(urlSlice); doc.Runs.Add(new Run(new Slice(labelStart, labelLen), RunStyle.Link, urlIndex)); i = urlClose + 1; plainStart = i; continue; } } // Operator reference: [Identifier] // UI-topic reference: [ui:Identifier] or with an explicit label [ui:Identifier|display text] if (labelLen > 0) { // A UI-topic ref may carry a "|display text" label so prose can read naturally // ("the [ui:ParameterWindow|Parameter window]") while still linking the ui: key. var pipe = IndexOf(src, labelStart, close, '|'); var keyLen = (pipe >= 0 ? pipe : close) - labelStart; var isUiTopic = IsUiTopicRef(src, labelStart, keyLen); var isOpRef = pipe < 0 && IsIdentifier(src, labelStart, labelLen); if (isUiTopic || isOpRef) { FlushPlain(src, plainStart, i, doc); // The key the handler resolves ("ui:Graph" or the operator name). var keySlice = new Slice(labelStart, keyLen); var urlIndex = doc.Urls.Count; doc.Urls.Add(keySlice); // Displayed text: the explicit "|label", else the key with the "ui:" prefix dropped. Slice textSlice; if (pipe >= 0) textSlice = new Slice(pipe + 1, close - (pipe + 1)); else if (isUiTopic) textSlice = new Slice(labelStart + 3, keyLen - 3); // drop the "ui:" prefix else textSlice = keySlice; doc.Runs.Add(new Run(textSlice, RunStyle.OpRef, urlIndex)); i = close + 1; plainStart = i; continue; } } } } i++; } FlushPlain(src, plainStart, to, doc); } private static void FlushPlain(string src, int from, int to, ParsedDoc doc) { if (to > from) doc.Runs.Add(new Run(new Slice(from, to - from), RunStyle.None, -1)); } private static int IndexOf(string src, int from, int to, char target) { for (var i = from; i < to; i++) { if (src[i] == target) return i; } return -1; } private static int IndexOfDouble(string src, int from, int to, char target) { for (var i = from; i < to - 1; i++) { if (src[i] == target && src[i + 1] == target) return i; } return -1; } private static bool IsIdentifier(string src, int from, int length) { for (var i = 0; i < length; i++) { var c = src[from + i]; var ok = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_'; if (!ok) return false; } return true; } /// A help-index UI-topic link: [ui:Identifier] (the ui: prefix then a plain identifier). private static bool IsUiTopicRef(string src, int from, int length) { return length > 3 && src[from] == 'u' && src[from + 1] == 'i' && src[from + 2] == ':' && IsIdentifier(src, from + 3, length - 3); } }