diff --git a/TUnit.Engine/Reporters/Html/TestReport.template.html b/TUnit.Engine/Reporters/Html/TestReport.template.html
index 84a79967d5..f3085bab79 100644
--- a/TUnit.Engine/Reporters/Html/TestReport.template.html
+++ b/TUnit.Engine/Reporters/Html/TestReport.template.html
@@ -35,6 +35,14 @@
--shadow-sm: 0 1px 2px rgba(15,15,15,0.04);
--shadow: 0 1px 2px rgba(15,15,15,0.04), 0 1px 4px rgba(15,15,15,0.04);
--shadow-pop:0 12px 32px rgba(15,15,15,0.10), 0 0 0 1px rgba(15,15,15,0.05);
+ /* C# syntax highlighting — matches the docs site's Prism "github" light theme. */
+ --syn-plain: #393a34;
+ --syn-comment: #999988;
+ --syn-string: #e3116c;
+ --syn-keyword: #00009f;
+ --syn-number: #36acaa;
+ --syn-func: #d73a49;
+ --syn-type: #6f42c1;
}
:root[data-theme="dark"] {
--bg: #0a0a0a;
@@ -65,6 +73,14 @@
--shadow-sm: 0 1px 2px rgba(0,0,0,0.4);
--shadow: 0 1px 2px rgba(0,0,0,0.4), 0 0 0 1px rgba(255,255,255,0.02);
--shadow-pop:0 24px 60px rgba(0,0,0,0.6), 0 0 0 1px rgba(255,255,255,0.04);
+ /* C# syntax highlighting — matches the docs site's Prism "dracula" dark theme. */
+ --syn-plain: #f8f8f2;
+ --syn-comment: #6272a4;
+ --syn-string: #ff79c6;
+ --syn-keyword: #bd93f9;
+ --syn-number: #bd93f9;
+ --syn-func: #50fa7b;
+ --syn-type: #8be9fd;
}
*, *::before, *::after { box-sizing: border-box; }
@@ -587,7 +603,15 @@
user-select: none; min-width: 3ch; text-align: right;
color: var(--text-dim); opacity: 0.5; margin-right: 16px; flex-shrink: 0;
}
- .source-snippet-code .line-content { white-space: pre; color: var(--text); }
+ .source-snippet-code .line-content { white-space: pre; color: var(--syn-plain); }
+ /* Baked-in "good enough" C# syntax highlighting. Token colors are theme-aware
+ and switch with the light/dark toggle via the --syn-* custom properties. */
+ .source-snippet-code .tok-comment { color: var(--syn-comment); font-style: italic; }
+ .source-snippet-code .tok-string { color: var(--syn-string); }
+ .source-snippet-code .tok-keyword { color: var(--syn-keyword); }
+ .source-snippet-code .tok-number { color: var(--syn-number); }
+ .source-snippet-code .tok-func { color: var(--syn-func); }
+ .source-snippet-code .tok-type { color: var(--syn-type); }
/* ============================== run view ============================== */
.run-wrap { max-width: 1280px; margin: 0 auto; padding: 28px 28px 80px; }
@@ -1684,6 +1708,125 @@
Categories
const tmpl = (t.source.endLine && t.source.endLine > t.source.line && sl.rangeUrl) ? sl.rangeUrl : sl.lineUrl;
return fillSourceTemplate(tmpl, t);
}
+// ── Baked-in "good enough" C# syntax highlighter ──────────────────────────
+// A small hand-rolled tokenizer. Not a full C# parser — it deliberately trades
+// edge-case accuracy for zero dependencies and a tiny footprint. Output token
+// classes map to theme-aware --syn-* colors so highlighting follows the toggle.
+const CS_KEYWORDS = new Set((
+ 'abstract as async await base bool break byte case catch char checked class const ' +
+ 'continue decimal default delegate do double else enum event explicit extern false ' +
+ 'finally fixed float for foreach goto if implicit in int interface internal is lock ' +
+ 'long namespace new null object operator out override params private protected public ' +
+ 'readonly ref return sbyte sealed short sizeof stackalloc static string struct switch ' +
+ 'this throw true try typeof uint ulong unchecked unsafe ushort using virtual void ' +
+ 'volatile while ' +
+ // Contextual keywords — limited to ones that are nearly always keywords, so we
+ // don't miscolor common identifiers like `value`, `from`, `select`, `on`, `by`.
+ 'and dynamic file get global init nameof nint not notnull nuint or ' +
+ 'partial record required scoped set unmanaged var when where with yield'
+).split(' '));
+
+function highlightCSharpLines(code) {
+ // Tokenize the whole snippet (so multi-line comments / verbatim strings are
+ // handled), then split tokens across newlines into one HTML string per line.
+ const lines = [[]];
+ const push = (cls, text) => {
+ const parts = text.split('\n');
+ for (let i = 0; i < parts.length; i++) {
+ if (i > 0) lines.push([]);
+ if (parts[i] === '') continue;
+ const safe = esc(parts[i]);
+ lines[lines.length - 1].push(cls ? `${safe}` : safe);
+ }
+ };
+ const n = code.length;
+ let i = 0;
+ while (i < n) {
+ const c = code[i];
+ const c2 = code[i + 1];
+ // line comment / xml-doc comment
+ if (c === '/' && c2 === '/') {
+ let j = i + 2; while (j < n && code[j] !== '\n') j++;
+ push('tok-comment', code.slice(i, j)); i = j; continue;
+ }
+ // block comment
+ if (c === '/' && c2 === '*') {
+ let j = i + 2; while (j < n && !(code[j] === '*' && code[j + 1] === '/')) j++;
+ j = Math.min(n, j + 2);
+ push('tok-comment', code.slice(i, j)); i = j; continue;
+ }
+ // raw string literal: """ ... """ (optionally interpolated: $"""), 3+ quotes
+ if (c === '"' && c2 === '"' && code[i + 2] === '"') {
+ let q = 0; while (code[i + q] === '"') q++;
+ const fence = '"'.repeat(q);
+ let j = code.indexOf(fence, i + q);
+ j = j === -1 ? n : j + q;
+ push('tok-string', code.slice(i, j)); i = j; continue;
+ }
+ // verbatim / interpolated-verbatim string: @"...", $@"...", @$"..."
+ if (c === '@' && c2 === '"') { i = readVerbatim(code, i, 1, push); continue; }
+ if ((c === '$' && c2 === '@') || (c === '@' && c2 === '$')) {
+ if (code[i + 2] === '"') { i = readVerbatim(code, i, 2, push); continue; }
+ }
+ // regular or interpolated string: "..." / $"..."
+ if (c === '"' || (c === '$' && c2 === '"')) {
+ const start = c === '$' ? i + 1 : i;
+ let j = start + 1;
+ while (j < n && code[j] !== '"' && code[j] !== '\n') {
+ if (code[j] === '\\') j++;
+ j++;
+ }
+ j = Math.min(n, j + 1);
+ push('tok-string', code.slice(i, j)); i = j; continue;
+ }
+ // char literal
+ if (c === "'") {
+ let j = i + 1;
+ while (j < n && code[j] !== "'" && code[j] !== '\n') {
+ if (code[j] === '\\') j++;
+ j++;
+ }
+ j = Math.min(n, j + 1);
+ push('tok-string', code.slice(i, j)); i = j; continue;
+ }
+ // number
+ if (c >= '0' && c <= '9') {
+ 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));
+ const tok = m ? m[0] : c;
+ push('tok-number', tok); i += tok.length; continue;
+ }
+ // identifier / keyword (allow leading @ verbatim identifier)
+ if (/[A-Za-z_@]/.test(c)) {
+ const m = /^@?[A-Za-z_][A-Za-z0-9_]*/.exec(code.slice(i));
+ const word = m[0];
+ const bare = word[0] === '@' ? word.slice(1) : word;
+ let k = i + word.length; while (k < n && /\s/.test(code[k])) k++;
+ let cls = '';
+ if (CS_KEYWORDS.has(bare)) cls = 'tok-keyword';
+ else if (code[k] === '(') cls = 'tok-func'; // call / declaration
+ else if (/^[A-Z]/.test(bare)) cls = 'tok-type'; // PascalCase ⇒ type
+ push(cls, word); i += word.length; continue;
+ }
+ // anything else (punctuation, operators, whitespace) → plain
+ push('', c); i++;
+ }
+ return lines.map(parts => parts.join(''));
+}
+function readVerbatim(code, i, prefix, push) {
+ // prefix = number of chars before the opening quote (@ ⇒ 1, $@/@$ ⇒ 2).
+ // In verbatim strings a quote is escaped by doubling it ("").
+ const n = code.length;
+ let j = i + prefix + 1;
+ while (j < n) {
+ if (code[j] === '"') {
+ if (code[j + 1] === '"') { j += 2; continue; }
+ j++; break;
+ }
+ j++;
+ }
+ push('tok-string', code.slice(i, Math.min(n, j)));
+ return Math.min(n, j);
+}
const _snippetCache = new Map();
const SNIPPET_FALLBACK_LINES = 40;
function loadSourceSnippet(detail) {
@@ -1712,10 +1855,14 @@ Categories
const snippet = lines.slice(from, to);
const fileName = path.split('/').pop();
const rangeLabel = `lines ${startLine}–${from + snippet.length}`;
+ // Highlight C# files; fall back to plain (escaped) text for anything else.
+ const htmlLines = /\.cs$/i.test(fileName)
+ ? highlightCSharpLines(snippet.join('\n'))
+ : snippet.map(esc);
container.innerHTML =
`` +
`${snippet.map((l, i) =>
- `
${from + i + 1}${esc(l)}
`
+ `
${from + i + 1}${htmlLines[i] || ''}
`
).join('')}
`;
};