diff --git a/frontend/src/components/scripts/PowerShellHighlighter.tsx b/frontend/src/components/scripts/PowerShellHighlighter.tsx new file mode 100644 index 00000000..a27ad551 --- /dev/null +++ b/frontend/src/components/scripts/PowerShellHighlighter.tsx @@ -0,0 +1,97 @@ +/** + * Single-pass PowerShell syntax highlighter. + * + * Uses a combined alternation regex so tokens matched earlier in the list + * cannot be re-coloured by later rules (e.g. a variable inside a string + * is captured by the string rule and won't be re-matched by the variable rule). + * + * Priority order: + * 1. Comments /#[^\r\n]star/ + * 2. String literals /"[^"]*"|'[^']*'/ + * 3. Unfilled placeholders /\{\{[^}]+\}\}/ + * 4. Variables /\$\w+/ + * 5. Cmdlets /[A-Z][a-z]+-[A-Z][a-zA-Z]+/ + * 6. Parameters /-[A-Za-z]+/ + * 7. Keywords /\b(if|else|...)\b/ + * + * Note: variables (priority 4) consume $foreach before keywords (priority 7) + * can match — this is intentional PowerShell behaviour. + */ + +import React from 'react' + +const TOKEN_REGEX = new RegExp( + [ + /#[^\r\n]*/, // 1. comments + /"[^"]*"|'[^']*'/, // 2. string literals + /\{\{[^}]+\}\}/, // 3. unfilled placeholders + /\$\w+/, // 4. variables + /[A-Z][a-z]+-[A-Z][a-zA-Z]+/, // 5. cmdlets (Verb-Noun) + /-[A-Za-z]+/, // 6. parameters + /\b(?:if|else|elseif|foreach|for|while|function|return|try|catch|finally|param|switch)\b/, // 7. keywords + ] + .map(r => r.source) + .join('|'), + 'g' +) + +const TOKEN_CLASSES: Record = { + comment: 'text-[#8b949e]', + string: 'text-[#a5d6ff]', + placeholder: 'text-amber-400 underline decoration-dashed', + variable: 'text-[#79c0ff]', + cmdlet: 'text-[#22d3ee]', + parameter: 'text-[#d2a8ff]', + keyword: 'text-[#ff7b72]', +} + +const KEYWORDS = new Set([ + 'if', 'else', 'elseif', 'foreach', 'for', 'while', + 'function', 'return', 'try', 'catch', 'finally', 'param', 'switch', +]) + +function classify(token: string): string { + if (token.startsWith('#')) return 'comment' + if (token.startsWith('"') || token.startsWith("'")) return 'string' + if (token.startsWith('{{')) return 'placeholder' + if (token.startsWith('$')) return 'variable' + if (/^-[A-Za-z]+$/.test(token)) return 'parameter' + if (KEYWORDS.has(token)) return 'keyword' + return 'cmdlet' +} + +interface Props { + script: string +} + +export function PowerShellHighlighter({ script }: Props) { + const parts: React.ReactNode[] = [] + let lastIndex = 0 + + TOKEN_REGEX.lastIndex = 0 + let match: RegExpExecArray | null + + while ((match = TOKEN_REGEX.exec(script)) !== null) { + if (match.index > lastIndex) { + parts.push(script.slice(lastIndex, match.index)) + } + const token = match[0] + const kind = classify(token) + parts.push( + + {token} + + ) + lastIndex = match.index + token.length + } + + if (lastIndex < script.length) { + parts.push(script.slice(lastIndex)) + } + + return ( +
+      {parts}
+    
+ ) +}