using System; using System.Collections.Generic; using System.Text.RegularExpressions; namespace WandEnhancer.Core.Js { /// /// Navigates minified JavaScript by matching delimiters rather than by matching shape. /// Wand renames identifiers on every build but never renames its API endpoints, IPC /// channel names or public method names, so anchoring on those and walking the /// delimiter structure keeps a patch valid across builds. /// internal sealed class JsCursor { private const string RegexPrecedingChars = "(,=:[!&|?{};+-*%~^<>"; private const int NameLookbackChars = 128; private static readonly Regex NameBeforeParen = new Regex(@"[#\w$]+$"); private static readonly Regex FunctionKeyword = new Regex(@"(? BlockKeywords = new HashSet(StringComparer.Ordinal) { "if", "for", "while", "switch", "catch", "with", "do", "else" }; // A slash after one of these is a regex literal, not division. Minifiers emit // `return/re/.test(x)` with no space, so missing these desyncs the whole scan. private static readonly HashSet RegexPrecedingKeywords = new HashSet(StringComparer.Ordinal) { "return", "typeof", "instanceof", "in", "of", "new", "delete", "void", "throw", "case", "do", "else", "yield", "await" }; private readonly string _text; public JsCursor(string text) { _text = text; } public string Text => _text; public int IndexOf(string value, int from = 0) { return from >= _text.Length ? -1 : _text.IndexOf(value, from, StringComparison.Ordinal); } /// Index of the delimiter closing the one at , or -1. public int MatchClose(int openIndex) { char open = _text[openIndex]; char close = CloserOf(open); int depth = 0; for (int index = openIndex; index < _text.Length;) { char current = _text[index]; if (current == open) { depth++; index++; } else if (current == close) { if (--depth == 0) { return index; } index++; } else { index = SkipToken(index); } } return -1; } /// Open delimiters enclosing , innermost first. public List OpenerStack(int index) { var stack = new List(); for (int cursor = 0; cursor < index && cursor < _text.Length;) { char current = _text[cursor]; if (current == '{' || current == '(' || current == '[') { stack.Add(cursor); cursor++; } else if (current == '}' || current == ')' || current == ']') { if (stack.Count > 0) { stack.RemoveAt(stack.Count - 1); } cursor++; } else { cursor = SkipToken(cursor); } } stack.Reverse(); return stack; } /// Innermost enclosing delimiter of the given kind, or -1. public int EnclosingOpener(int index, char kind) { foreach (int opener in OpenerStack(index)) { if (_text[opener] == kind) { return opener; } } return -1; } /// Innermost named function or method whose body contains . public JsFunction EnclosingFunction(int index) { foreach (int opener in OpenerStack(index)) { if (_text[opener] != '{') { continue; } var function = ReadFunctionAt(opener); if (function != null) { return function; } } return null; } /// The named function whose body closes at , or null. public JsFunction FunctionEndingAt(int closeIndex) { if (closeIndex < 0 || closeIndex >= _text.Length || _text[closeIndex] != '}') { return null; } var stack = OpenerStack(closeIndex); return stack.Count == 0 ? null : ReadFunctionAt(stack[0]); } /// First function declared as name(...), ignoring property and call sites. public JsFunction FindFunction(string name) { var pattern = new Regex($@"(?First function name(...) { } declared at or after . public JsFunction FindFunctionAfter(int index) { var match = FunctionKeyword.Match(_text, index); if (!match.Success) { return null; } int closeParen = MatchClose(match.Index + match.Length - 1); if (closeParen < 0) { return null; } int bodyOpen = SkipWhitespaceForward(closeParen + 1); return bodyOpen < _text.Length && _text[bodyOpen] == '{' ? ReadFunctionAt(bodyOpen) : null; } /// /// Index of the opening parenthesis of callee(... "literal" ...), or -1. Wand reuses the /// same channel names for inbound listeners and outbound sends, so the callee disambiguates. /// public int FindCall(string callee, string literal) { for (int anchor = IndexOf(literal); anchor >= 0; anchor = IndexOf(literal, anchor + 1)) { int open = EnclosingOpener(anchor, '('); if (open >= 0 && NameBefore(open) == callee) { return open; } } return -1; } /// Trailing identifier directly before , e.g. send of a?.send(. public string NameBefore(int index) { int end = SkipWhitespaceBack(index - 1) + 1; var match = MatchNameEndingAt(end); return match.Success ? match.Value.TrimStart('#') : null; } /// Identifier ending at , searched in a bounded window so /// multi-megabyte bundles are not copied on every lookup. private Match MatchNameEndingAt(int end) { int windowStart = Math.Max(0, end - NameLookbackChars); return NameBeforeParen.Match(_text.Substring(windowStart, end - windowStart)); } public int SkipWhitespaceBack(int index) { while (index >= 0 && char.IsWhiteSpace(_text[index])) { index--; } return index; } public int SkipWhitespaceForward(int index) { while (index < _text.Length && char.IsWhiteSpace(_text[index])) { index++; } return index; } private JsFunction ReadFunctionAt(int bodyOpen) { int closeParen = SkipWhitespaceBack(bodyOpen - 1); if (closeParen < 0 || _text[closeParen] != ')') { return null; } var stack = OpenerStack(closeParen); if (stack.Count == 0 || _text[stack[0]] != '(') { return null; } int nameEnd = SkipWhitespaceBack(stack[0] - 1) + 1; var nameMatch = MatchNameEndingAt(nameEnd); if (!nameMatch.Success || BlockKeywords.Contains(nameMatch.Value)) { return null; } int bodyClose = MatchClose(bodyOpen); return bodyClose < 0 ? null : new JsFunction(nameMatch.Value, nameEnd - nameMatch.Length, bodyOpen, bodyClose, _text); } private int SkipToken(int index) { char current = _text[index]; if (current == '"' || current == '\'' || current == '`') { return SkipString(index, current); } if (current != '/' || index + 1 >= _text.Length) { return index + 1; } char next = _text[index + 1]; if (next == '/') { int lineEnd = _text.IndexOf('\n', index); return lineEnd < 0 ? _text.Length : lineEnd + 1; } if (next == '*') { int commentEnd = _text.IndexOf("*/", index + 2, StringComparison.Ordinal); return commentEnd < 0 ? _text.Length : commentEnd + 2; } return StartsRegexLiteral(index) ? SkipRegexLiteral(index) : index + 1; } private int SkipString(int index, char quote) { for (int cursor = index + 1; cursor < _text.Length; cursor++) { char current = _text[cursor]; if (current == '\\') { cursor++; } else if (current == quote) { return cursor + 1; } else if (quote == '`' && current == '$' && cursor + 1 < _text.Length && _text[cursor + 1] == '{') { int interpolationEnd = MatchClose(cursor + 1); cursor = interpolationEnd < 0 ? _text.Length : interpolationEnd; } } return _text.Length; } private int SkipRegexLiteral(int index) { bool inCharacterClass = false; for (int cursor = index + 1; cursor < _text.Length; cursor++) { char current = _text[cursor]; if (current == '\\') { cursor++; } else if (current == '[') { inCharacterClass = true; } else if (current == ']') { inCharacterClass = false; } else if (current == '\n') { return index + 1; } else if (current == '/' && !inCharacterClass) { return cursor + 1; } } return _text.Length; } private bool StartsRegexLiteral(int index) { int previous = SkipWhitespaceBack(index - 1); if (previous < 0 || RegexPrecedingChars.IndexOf(_text[previous]) >= 0) { return true; } return IsIdentifierChar(_text[previous]) && RegexPrecedingKeywords.Contains(WordEndingAt(previous)); } /// The identifier ending at inclusive, or "" when there is none. private string WordEndingAt(int end) { int start = end; while (start >= 0 && IsIdentifierChar(_text[start])) { start--; } // A preceding '.' makes it a member name (`x.in`), never a keyword. if (start >= 0 && _text[start] == '.') { return string.Empty; } return _text.Substring(start + 1, end - start); } private static bool IsIdentifierChar(char value) { return char.IsLetterOrDigit(value) || value == '_' || value == '$'; } private static char CloserOf(char open) { switch (open) { case '{': return '}'; case '(': return ')'; case '[': return ']'; default: throw new ArgumentException($"Not an opening delimiter: {open}", nameof(open)); } } } }