Skip to content

Commit cd2189d

Browse files
slang25claude
andauthored
feat(html-report): baked-in C# syntax highlighting on Source tab (#6132)
Add a small, zero-dependency C# syntax highlighter to the HTML test report's Source tab. A hand-rolled tokenizer classifies comments, strings (regular/verbatim/interpolated/raw), char and numeric literals, keywords, method calls and PascalCase types, emitting theme-aware token spans. Token colors are driven by --syn-* CSS custom properties defined per theme, so highlighting follows the existing light/dark toggle with no re-render. The palette matches the docs site's Prism themes (github for light, dracula for dark). Non-.cs files fall back to plain escaped text. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent cf904db commit cd2189d

1 file changed

Lines changed: 149 additions & 2 deletions

File tree

TUnit.Engine/Reporters/Html/TestReport.template.html

Lines changed: 149 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,14 @@
3535
--shadow-sm: 0 1px 2px rgba(15,15,15,0.04);
3636
--shadow: 0 1px 2px rgba(15,15,15,0.04), 0 1px 4px rgba(15,15,15,0.04);
3737
--shadow-pop:0 12px 32px rgba(15,15,15,0.10), 0 0 0 1px rgba(15,15,15,0.05);
38+
/* C# syntax highlighting — matches the docs site's Prism "github" light theme. */
39+
--syn-plain: #393a34;
40+
--syn-comment: #999988;
41+
--syn-string: #e3116c;
42+
--syn-keyword: #00009f;
43+
--syn-number: #36acaa;
44+
--syn-func: #d73a49;
45+
--syn-type: #6f42c1;
3846
}
3947
:root[data-theme="dark"] {
4048
--bg: #0a0a0a;
@@ -65,6 +73,14 @@
6573
--shadow-sm: 0 1px 2px rgba(0,0,0,0.4);
6674
--shadow: 0 1px 2px rgba(0,0,0,0.4), 0 0 0 1px rgba(255,255,255,0.02);
6775
--shadow-pop:0 24px 60px rgba(0,0,0,0.6), 0 0 0 1px rgba(255,255,255,0.04);
76+
/* C# syntax highlighting — matches the docs site's Prism "dracula" dark theme. */
77+
--syn-plain: #f8f8f2;
78+
--syn-comment: #6272a4;
79+
--syn-string: #ff79c6;
80+
--syn-keyword: #bd93f9;
81+
--syn-number: #bd93f9;
82+
--syn-func: #50fa7b;
83+
--syn-type: #8be9fd;
6884
}
6985

7086
*, *::before, *::after { box-sizing: border-box; }
@@ -587,7 +603,15 @@
587603
user-select: none; min-width: 3ch; text-align: right;
588604
color: var(--text-dim); opacity: 0.5; margin-right: 16px; flex-shrink: 0;
589605
}
590-
.source-snippet-code .line-content { white-space: pre; color: var(--text); }
606+
.source-snippet-code .line-content { white-space: pre; color: var(--syn-plain); }
607+
/* Baked-in "good enough" C# syntax highlighting. Token colors are theme-aware
608+
and switch with the light/dark toggle via the --syn-* custom properties. */
609+
.source-snippet-code .tok-comment { color: var(--syn-comment); font-style: italic; }
610+
.source-snippet-code .tok-string { color: var(--syn-string); }
611+
.source-snippet-code .tok-keyword { color: var(--syn-keyword); }
612+
.source-snippet-code .tok-number { color: var(--syn-number); }
613+
.source-snippet-code .tok-func { color: var(--syn-func); }
614+
.source-snippet-code .tok-type { color: var(--syn-type); }
591615

592616
/* ============================== run view ============================== */
593617
.run-wrap { max-width: 1280px; margin: 0 auto; padding: 28px 28px 80px; }
@@ -1684,6 +1708,125 @@ <h2>Categories</h2>
16841708
const tmpl = (t.source.endLine && t.source.endLine > t.source.line && sl.rangeUrl) ? sl.rangeUrl : sl.lineUrl;
16851709
return fillSourceTemplate(tmpl, t);
16861710
}
1711+
// ── Baked-in "good enough" C# syntax highlighter ──────────────────────────
1712+
// A small hand-rolled tokenizer. Not a full C# parser — it deliberately trades
1713+
// edge-case accuracy for zero dependencies and a tiny footprint. Output token
1714+
// classes map to theme-aware --syn-* colors so highlighting follows the toggle.
1715+
const CS_KEYWORDS = new Set((
1716+
'abstract as async await base bool break byte case catch char checked class const ' +
1717+
'continue decimal default delegate do double else enum event explicit extern false ' +
1718+
'finally fixed float for foreach goto if implicit in int interface internal is lock ' +
1719+
'long namespace new null object operator out override params private protected public ' +
1720+
'readonly ref return sbyte sealed short sizeof stackalloc static string struct switch ' +
1721+
'this throw true try typeof uint ulong unchecked unsafe ushort using virtual void ' +
1722+
'volatile while ' +
1723+
// Contextual keywords — limited to ones that are nearly always keywords, so we
1724+
// don't miscolor common identifiers like `value`, `from`, `select`, `on`, `by`.
1725+
'and dynamic file get global init nameof nint not notnull nuint or ' +
1726+
'partial record required scoped set unmanaged var when where with yield'
1727+
).split(' '));
1728+
1729+
function highlightCSharpLines(code) {
1730+
// Tokenize the whole snippet (so multi-line comments / verbatim strings are
1731+
// handled), then split tokens across newlines into one HTML string per line.
1732+
const lines = [[]];
1733+
const push = (cls, text) => {
1734+
const parts = text.split('\n');
1735+
for (let i = 0; i < parts.length; i++) {
1736+
if (i > 0) lines.push([]);
1737+
if (parts[i] === '') continue;
1738+
const safe = esc(parts[i]);
1739+
lines[lines.length - 1].push(cls ? `<span class="${cls}">${safe}</span>` : safe);
1740+
}
1741+
};
1742+
const n = code.length;
1743+
let i = 0;
1744+
while (i < n) {
1745+
const c = code[i];
1746+
const c2 = code[i + 1];
1747+
// line comment / xml-doc comment
1748+
if (c === '/' && c2 === '/') {
1749+
let j = i + 2; while (j < n && code[j] !== '\n') j++;
1750+
push('tok-comment', code.slice(i, j)); i = j; continue;
1751+
}
1752+
// block comment
1753+
if (c === '/' && c2 === '*') {
1754+
let j = i + 2; while (j < n && !(code[j] === '*' && code[j + 1] === '/')) j++;
1755+
j = Math.min(n, j + 2);
1756+
push('tok-comment', code.slice(i, j)); i = j; continue;
1757+
}
1758+
// raw string literal: """ ... """ (optionally interpolated: $"""), 3+ quotes
1759+
if (c === '"' && c2 === '"' && code[i + 2] === '"') {
1760+
let q = 0; while (code[i + q] === '"') q++;
1761+
const fence = '"'.repeat(q);
1762+
let j = code.indexOf(fence, i + q);
1763+
j = j === -1 ? n : j + q;
1764+
push('tok-string', code.slice(i, j)); i = j; continue;
1765+
}
1766+
// verbatim / interpolated-verbatim string: @"...", $@"...", @$"..."
1767+
if (c === '@' && c2 === '"') { i = readVerbatim(code, i, 1, push); continue; }
1768+
if ((c === '$' && c2 === '@') || (c === '@' && c2 === '$')) {
1769+
if (code[i + 2] === '"') { i = readVerbatim(code, i, 2, push); continue; }
1770+
}
1771+
// regular or interpolated string: "..." / $"..."
1772+
if (c === '"' || (c === '$' && c2 === '"')) {
1773+
const start = c === '$' ? i + 1 : i;
1774+
let j = start + 1;
1775+
while (j < n && code[j] !== '"' && code[j] !== '\n') {
1776+
if (code[j] === '\\') j++;
1777+
j++;
1778+
}
1779+
j = Math.min(n, j + 1);
1780+
push('tok-string', code.slice(i, j)); i = j; continue;
1781+
}
1782+
// char literal
1783+
if (c === "'") {
1784+
let j = i + 1;
1785+
while (j < n && code[j] !== "'" && code[j] !== '\n') {
1786+
if (code[j] === '\\') j++;
1787+
j++;
1788+
}
1789+
j = Math.min(n, j + 1);
1790+
push('tok-string', code.slice(i, j)); i = j; continue;
1791+
}
1792+
// number
1793+
if (c >= '0' && c <= '9') {
1794+
const m = /^(?:0[xX][0-9a-fA-F_]+|0[bB][01_]+|[0-9][0-9_]*(?:\.[0-9_]+)?(?:[eE][+-]?[0-9]+)?)[fFdDmMuUlL]*/.exec(code.slice(i));
1795+
const tok = m ? m[0] : c;
1796+
push('tok-number', tok); i += tok.length; continue;
1797+
}
1798+
// identifier / keyword (allow leading @ verbatim identifier)
1799+
if (/[A-Za-z_@]/.test(c)) {
1800+
const m = /^@?[A-Za-z_][A-Za-z0-9_]*/.exec(code.slice(i));
1801+
const word = m[0];
1802+
const bare = word[0] === '@' ? word.slice(1) : word;
1803+
let k = i + word.length; while (k < n && /\s/.test(code[k])) k++;
1804+
let cls = '';
1805+
if (CS_KEYWORDS.has(bare)) cls = 'tok-keyword';
1806+
else if (code[k] === '(') cls = 'tok-func'; // call / declaration
1807+
else if (/^[A-Z]/.test(bare)) cls = 'tok-type'; // PascalCase ⇒ type
1808+
push(cls, word); i += word.length; continue;
1809+
}
1810+
// anything else (punctuation, operators, whitespace) → plain
1811+
push('', c); i++;
1812+
}
1813+
return lines.map(parts => parts.join(''));
1814+
}
1815+
function readVerbatim(code, i, prefix, push) {
1816+
// prefix = number of chars before the opening quote (@ ⇒ 1, $@/@$ ⇒ 2).
1817+
// In verbatim strings a quote is escaped by doubling it ("").
1818+
const n = code.length;
1819+
let j = i + prefix + 1;
1820+
while (j < n) {
1821+
if (code[j] === '"') {
1822+
if (code[j + 1] === '"') { j += 2; continue; }
1823+
j++; break;
1824+
}
1825+
j++;
1826+
}
1827+
push('tok-string', code.slice(i, Math.min(n, j)));
1828+
return Math.min(n, j);
1829+
}
16871830
const _snippetCache = new Map();
16881831
const SNIPPET_FALLBACK_LINES = 40;
16891832
function loadSourceSnippet(detail) {
@@ -1712,10 +1855,14 @@ <h2>Categories</h2>
17121855
const snippet = lines.slice(from, to);
17131856
const fileName = path.split('/').pop();
17141857
const rangeLabel = `lines ${startLine}${from + snippet.length}`;
1858+
// Highlight C# files; fall back to plain (escaped) text for anything else.
1859+
const htmlLines = /\.cs$/i.test(fileName)
1860+
? highlightCSharpLines(snippet.join('\n'))
1861+
: snippet.map(esc);
17151862
container.innerHTML =
17161863
`<div class="source-snippet-header"><span>${esc(fileName)} (${rangeLabel})</span></div>` +
17171864
`<div class="source-snippet-code">${snippet.map((l, i) =>
1718-
`<div class="line"><span class="line-num">${from + i + 1}</span><span class="line-content">${esc(l)}</span></div>`
1865+
`<div class="line"><span class="line-num">${from + i + 1}</span><span class="line-content">${htmlLines[i] || ''}</span></div>`
17191866
).join('')}</div>`;
17201867
};
17211868

0 commit comments

Comments
 (0)