diff --git a/CHANGELOG.md b/CHANGELOG.md index f3952bc..b5bcb8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ All notable user-facing changes to Rustwright are documented in this file. - Added native pending-file-chooser events and mirror-profile MCP file upload and cancellation with workspace-confined paths. - Added native physical element dragging and the mirror-profile `browser_drag` MCP tool. - Added alpha bindings for Go, Java, C#/.NET, Ruby, and PHP, plus a native Rust API, backed by the shared Rust engine. +- Added shadow DOM support to frame enumeration: iframes attached inside a shadow root now appear in `page.frames` and `frame.child_frames`, so widgets that mount their cross-origin iframe that way (Cloudflare Turnstile, hCaptcha, and embedded payment fields among them) are reachable. ### Changed @@ -26,6 +27,12 @@ All notable user-facing changes to Rustwright are documented in this file. ### Fixed +- Fixed `page.evaluate()` treating an already-invoked IIFE as a function literal when its body contained an arrow function anywhere, which wrapped and re-called the value it had returned and failed with `__rw_fn is not a function`. +- Fixed the best-effort frame-tree refresh spending the caller's whole timeout per session, which made `Request.frame` inside a route handler stall the navigation it belonged to until that timeout expired, and hang indefinitely when the timeout was disabled. +- Fixed the stealth user-agent override pinning `Accept-Language` to `en-US,en`, which silently overrode `--accept-lang`/`--lang` and left a browser configured for one region reporting that region's timezone alongside an `en-US` locale. +- Fixed dedicated workers being given their identity by rewriting the page's `Worker` constructor to load a generated blob that `importScripts` the real script, which moved every worker off its own URL and changed its `location` and origin; worker identity is now installed over the worker's own CDP session, as it already was for service workers, and the worker keeps its real script URL. +- Fixed the stealth init script removing `navigator.webdriver` from browsers that already report `false`, replacing a value every real Chrome exposes with a missing property. +- Fixed child-frame enumeration pairing the DOM query and the protocol's children by position, which gave a light-DOM frame the identity of a shadow-root frame that preceded it; the two are now correlated by frame identity. - Fixed locator waits so they re-arm after mid-wait navigation against the original timeout instead of surfacing execution-context errors. - Fixed remote-CDP actionability probes so they receive the full remaining action budget rather than a short per-probe cap. - Fixed Node.js evaluation decoding for special numeric values, BigInt, and regular expressions; Go/C-ABI and native Rust now use the core's canonical wire decoder. diff --git a/python/rustwright/sync_api.py b/python/rustwright/sync_api.py index 780d5da..965e689 100644 --- a/python/rustwright/sync_api.py +++ b/python/rustwright/sync_api.py @@ -13386,10 +13386,32 @@ def _child_frame_entries(self) -> list[dict[str, Any]]: if isinstance(child, dict) ] if isinstance(entries, list): - for index, entry in enumerate(entries): - if not isinstance(entry, dict) or index >= len(cdp_children): + # The DOM query orders and names the frames it can see; the protocol decides which + # exist. A frame in a shadow root is an ordinary child of the tree and invisible to + # `querySelectorAll`, so pairing the two by position would hand a light-DOM frame a + # shadow frame's identity. Match on URL, fall back to order, keep what was missed. + available = [child for child in cdp_children if isinstance(child, dict)] + claimed: set[int] = set() + + def claim(entry: dict[str, Any]) -> Optional[dict[str, Any]]: + entry_url = str(entry.get("url") or "") + if entry_url: + for position, child in enumerate(available): + if position not in claimed and str(child.get("url") or "") == entry_url: + claimed.add(position) + return child + for position, child in enumerate(available): + if position not in claimed: + claimed.add(position) + return child + return None + + for entry in entries: + if not isinstance(entry, dict): + continue + cdp_frame = claim(entry) + if cdp_frame is None: continue - cdp_frame = cdp_children[index] frame_id = cdp_frame.get("id") if frame_id: entry["id"] = str(frame_id) @@ -13397,6 +13419,17 @@ def _child_frame_entries(self) -> list[dict[str, Any]]: entry["url"] = str(cdp_frame.get("url") or "") if not entry.get("name") and cdp_frame.get("name"): entry["name"] = str(cdp_frame.get("name") or "") + for position, cdp_frame in enumerate(available): + if position in claimed: + continue + entries.append( + { + "id": cdp_frame.get("id"), + "name": cdp_frame.get("name") or "", + "url": cdp_frame.get("url") or "", + "frame_index": len(entries), + } + ) return entries if isinstance(entries, list) else [] def _wrap_spec(self, spec: Dict[str, Any]) -> Dict[str, Any]: @@ -14849,13 +14882,17 @@ def _frame_from_spec(self, frame_spec: Dict[str, Any]) -> Frame: def _cdp_frame_tree_root(self) -> Optional[dict[str, Any]]: frame_tree = getattr(self._core, "frame_tree", None) if frame_tree is not None: + # The core owns this tree: it answers from the frame state it maintains from protocol + # events and refreshes it on a bounded best-effort budget. An empty tree is an answer + # -- a document whose request is still paused has not committed a frame tree yet -- + # so re-asking over a raw session here would only re-run the round trip the core just + # bounded, at `CDPSession.send`'s fixed 30s, once per frame walked. try: payload = json.loads(_call(frame_tree, self._default_timeout)) - root = payload.get("frameTree") if isinstance(payload, dict) else None - if isinstance(root, dict): - return root except Exception: - pass + return None + root = payload.get("frameTree") if isinstance(payload, dict) else None + return root if isinstance(root, dict) else None try: session = CDPSession(_call(self._core.cdp_session)) payload = session.send("Page.getFrameTree") diff --git a/src/lib.rs b/src/lib.rs index 23c9111..9277150 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1310,6 +1310,15 @@ impl std::fmt::Display for ActionTimeoutError { impl std::error::Error for ActionTimeoutError {} const MAX_FRAME_TREE_DEPTH: usize = 256; +/// Longest a single `Page.getFrameTree` may block while refreshing cached frame state. +/// +/// The refresh is best effort — a session that does not answer is skipped and the cached tree is +/// returned anyway — so it must never spend the caller's whole timeout. A renderer will not answer +/// this command while a `Fetch` interception is paused on its document request, which is exactly +/// the state a route handler runs in: reading `Request.frame` there gave every session the full +/// remaining deadline, turning a frame walk into one deadline burn per frame (measured at 38s for +/// a 4s default timeout) and, with the timeout disabled, a 24-hour wait. +const FRAME_TREE_REFRESH_BUDGET: Duration = Duration::from_millis(1_000); /// A terminal element state observed by an auto-waiting physical action. /// @@ -3718,6 +3727,152 @@ multiline-compatible = """4.5.6""" assert_eq!(wrapped, r#"(0, eval)("const s = \"a\\tb\";\ns;")"#); } + #[test] + fn iife_containing_an_arrow_is_not_treated_as_a_function() { + // `looks_like_function` scanned for the first `=>` anywhere in the source and accepted + // the expression when the text before it started with `(`. An IIFE starts with `(`, so + // any IIFE whose body happens to contain an arrow was misread as a function literal, + // wrapped as `const __rw_fn = ()` and called again. The IIFE has already run by + // then, so `__rw_fn` holds its result and the call fails with + // "__rw_fn is not a function". + let script = "(function () { return [1, 2].map((value) => value * 2); })()"; + assert!(!looks_like_function(script)); + let wrapped = make_evaluate_expression(script, None); + assert!( + !wrapped.contains("__rw_fn"), + "an already-invoked IIFE must not be wrapped and called again: {wrapped}" + ); + } + + #[test] + fn parenthesised_expression_containing_an_arrow_is_not_a_function() { + // Same root cause without an IIFE: any parenthesised expression that mentions an arrow + // deeper inside is not itself an arrow function. + assert!(!looks_like_function("(items.map((value) => value))")); + assert!(!looks_like_function("(a === 1 ? b : (value) => value)()")); + } + + #[test] + fn genuine_arrow_and_function_literals_are_still_detected() { + // The guard must not over-correct: these are the shapes callers legitimately pass. + assert!(looks_like_function("() => 1")); + assert!(looks_like_function("(x) => x + 1")); + assert!(looks_like_function("(a, b) => a + b")); + assert!(looks_like_function("async (x) => x")); + assert!(looks_like_function("x => x")); + assert!(looks_like_function("async x => x")); + assert!(looks_like_function("function () { return 1; }")); + assert!(looks_like_function("async function () { return 1; }")); + assert!(looks_like_function( + "(first, second) => {\n return first + second;\n}" + )); + } + + #[test] + fn webdriver_guard_does_not_short_circuit_the_rest_of_the_stealth_script() { + // Returning out of the script instead would drop the chrome object, console history and + // worker identity that follow it, on every default launch: the launch args already clear + // the automation flag, so `navigator.webdriver` is `false` on the common path. + let script = stealth_init_script(); + let guard = script + .find("if (navigator.webdriver !== false)") + .expect("webdriver handling is guarded"); + let chrome_object = script + .find("const chromeObject") + .expect("stealth script installs the chrome object"); + assert!(guard < chrome_object); + assert!( + !script.contains("return;"), + "an early return would skip the rest of the script for every default launch" + ); + + // The guarded block has to close before the chrome object, or that setup sits inside it. + let mut depth = 0usize; + let open = script[guard..].find('{').expect("guard opens a block") + guard; + let mut index = open; + for character in script[open..].chars() { + match character { + '{' => depth += 1, + '}' => depth -= 1, + _ => {} + } + if depth == 0 { + break; + } + index += character.len_utf8(); + } + assert!( + index < chrome_object, + "webdriver guard swallows the chrome object setup" + ); + } + + #[test] + fn frame_tree_refresh_is_bounded_regardless_of_the_caller_timeout() { + // The refresh is best effort: a session that does not answer is skipped and the cached + // tree is returned anyway, so it must never spend the caller's whole budget. A renderer + // does not answer `Page.getFrameTree` while a Fetch interception is paused on its + // document request, which is the state a route handler runs in -- reading `Request.frame` + // there used to burn one full deadline per session walked. + assert_eq!( + frame_tree_refresh_timeout(Duration::from_secs(30)), + FRAME_TREE_REFRESH_BUDGET + ); + // `command_timeout` maps a disabled timeout to 24 hours; that must not become a 24-hour + // wait for an optional refresh. + assert_eq!( + frame_tree_refresh_timeout(BrowserInner::command_timeout(Some(0.0))), + FRAME_TREE_REFRESH_BUDGET + ); + // A caller asking for less than the budget still gets their shorter deadline. + assert_eq!( + frame_tree_refresh_timeout(Duration::from_millis(50)), + Duration::from_millis(50) + ); + } + + #[test] + fn keyword_prefixed_identifiers_are_not_function_literals() { + // `functionality()` and `asyncWork()` start with the keyword's letters but are calls. + assert!(!looks_like_function("functionality()")); + assert!(!looks_like_function("asyncWork()")); + assert!(!looks_like_function("functions.map(f => f())")); + } + + #[test] + fn parameter_list_scan_ignores_literals_and_comments() { + // The arrow binding the parameter list is found by matching the opening parenthesis, so + // parentheses inside strings, template literals and comments must not close it early. + assert!(looks_like_function(r#"(a = ")") => a"#)); + assert!(looks_like_function("(a = '(') => a")); + assert!(looks_like_function("(a = `)`) => a")); + assert!(looks_like_function("(a /* ) */) => a")); + assert!(looks_like_function("(a // )\n) => a")); + // ...and an unterminated literal must not be read as a function either. + assert!(!looks_like_function("(a = \") => a")); + } + + #[test] + fn regex_literals_in_the_parameter_list_do_not_end_it() { + // A `)` inside a regular expression is not the end of the parameter list. + assert!(looks_like_function( + "(pattern = /[)]/) => pattern.test(')')" + )); + assert!(looks_like_function("(pattern = /a\\/b)/) => pattern")); + // Division after a value is still division, not a literal opening. + assert!(looks_like_function("(a, b = x / y) => a + b")); + // ...and a call whose argument holds a regex is still not a function literal. + assert!(!looks_like_function("wrap(/[)]/)")); + } + + #[test] + fn nested_parameter_lists_still_resolve_to_the_binding_arrow() { + assert!(looks_like_function("({ a, b: [c] }) => a + c")); + assert!(looks_like_function("(a = (1 + 2)) => a")); + // A call whose argument is an arrow is not itself a function literal. + assert!(!looks_like_function("wrap((value) => value)")); + } + #[test] fn function_and_arg_scripts_are_not_double_wrapped_in_eval() { // Functions and arg-bearing calls keep their IIFE wrapping, which @@ -20118,8 +20273,13 @@ mod native_network_record_tests { } } +/// How long a best-effort frame-tree refresh may take, given the caller's timeout. +fn frame_tree_refresh_timeout(timeout: Duration) -> Duration { + timeout.min(FRAME_TREE_REFRESH_BUDGET) +} + async fn refresh_page_frame_tree(page: &Arc, timeout: Duration) -> RwResult<()> { - let deadline = OperationDeadline::new(timeout); + let deadline = OperationDeadline::new(frame_tree_refresh_timeout(timeout)); let sessions = page.frame_state.lock().unwrap().session_ids(); for session_id in sessions { let Ok(remaining) = deadline.remaining() else { @@ -20128,7 +20288,12 @@ async fn refresh_page_frame_tree(page: &Arc, timeout: Duration) -> Rw let result = page .browser .client - .send("Page.getFrameTree", json!({}), Some(&session_id), remaining) + .send( + "Page.getFrameTree", + json!({}), + Some(&session_id), + frame_tree_refresh_timeout(remaining), + ) .await; let Ok(tree) = result else { continue; @@ -20204,9 +20369,13 @@ async fn install_stealth_defaults(browser: &BrowserInner, session_id: &str) -> R .map(|user_agent| { let user_agent = user_agent.replace("HeadlessChrome/", "Chrome/"); let user_agent_metadata = stealth_user_agent_metadata(&user_agent, None); + // No `acceptLanguage`: omitting it leaves whatever the browser was configured + // with, which is the only value that can be coherent with its other geo signals. + // Pinning one here silently overrode `--accept-lang`/`--lang`, so a browser + // launched for one region reported that region's timezone alongside `en-US` -- + // an incoherence anti-bot checks read as a spoofed environment. json!({ "userAgent": user_agent, - "acceptLanguage": "en-US,en", "userAgentMetadata": user_agent_metadata, }) }) @@ -20289,7 +20458,7 @@ fn start_service_worker_stealth_auto_attach_cancelable( "filter": [ { "type": "page", "exclude": true }, { "type": "iframe", "exclude": true }, - { "type": "worker", "exclude": true }, + { "type": "worker", "exclude": false }, { "type": "shared_worker", "exclude": true }, { "type": "background_page", "exclude": true }, { "type": "service_worker", "exclude": false }, @@ -20318,7 +20487,12 @@ fn start_service_worker_stealth_auto_attach_cancelable( else { continue; }; - if info.get("type").and_then(Value::as_str) != Some("service_worker") { + // Dedicated workers get the identity script the same way service workers do: + // over their own session, while they are still paused. Rewriting the page's + // `Worker` constructor to load a blob shim instead moved the worker off its real + // script URL, which changes its `location` and origin. + let target_type = info.get("type").and_then(Value::as_str); + if !matches!(target_type, Some("service_worker") | Some("worker")) { let _ = client .send( "Runtime.runIfWaitingForDebugger", @@ -20460,26 +20634,31 @@ fn worker_stealth_init_script() -> String { const STEALTH_INIT_SCRIPT_TEMPLATE: &str = r#" (() => { - try { - delete Navigator.prototype.webdriver; - } catch (_) { - try { - delete navigator.webdriver; - } catch (_) {} - } - if ('webdriver' in navigator) { + // A browser already reporting `false` gives the answer every real Chrome gives; removing the + // property there trades a correct answer for a missing one. Guards only this block: the + // default launch args clear the flag, so the rest still has to run on an ordinary launch. + if (navigator.webdriver !== false) { try { - Object.defineProperty(Navigator.prototype, 'webdriver', { - get: () => undefined, - configurable: true - }); + delete Navigator.prototype.webdriver; } catch (_) { try { - Object.defineProperty(navigator, 'webdriver', { + delete navigator.webdriver; + } catch (_) {} + } + if ('webdriver' in navigator) { + try { + Object.defineProperty(Navigator.prototype, 'webdriver', { get: () => undefined, configurable: true }); - } catch (_) {} + } catch (_) { + try { + Object.defineProperty(navigator, 'webdriver', { + get: () => undefined, + configurable: true + }); + } catch (_) {} + } } } try { @@ -20743,104 +20922,6 @@ const STEALTH_INIT_SCRIPT_TEMPLATE: &str = r#" window.addEventListener('error', errorHandler, true); window.addEventListener('unhandledrejection', rejectionHandler, true); } catch (_) {} - try { - const workerMarker = Symbol.for('nativeWorkerIdentityWrapped'); - const NativeWorker = window.Worker; - if (typeof NativeWorker === 'function' && !NativeWorker[workerMarker]) { - const makeWorkerIdentitySource = () => { - const ua = String(navigator.userAgent || ''); - const appVersion = String(navigator.appVersion || ''); - const platform = String(navigator.platform || ''); - const language = String(navigator.language || 'en-US'); - const languages = Array.from(navigator.languages || [language]).map(String); - const chromeFullVersion = (ua.match(/Chrome\/([^\s]+)/) || [])[1] || ''; - const fullVersionForBrand = brand => { - const name = String(brand.brand || ''); - const version = String(brand.version || ''); - if ((name === 'Chromium' || name === 'Google Chrome') && chromeFullVersion) return chromeFullVersion; - if (name === 'Not A(Brand') return '24.0.0.0'; - return version; - }; - const mapBrand = brand => ({ - brand: String(brand.brand || ''), - version: String(brand.version || '') - }); - const mapFullVersionBrand = brand => ({ - brand: String(brand.brand || ''), - version: fullVersionForBrand(brand) - }); - const uaBrands = navigator.userAgentData ? Array.from(navigator.userAgentData.brands || []).map(mapBrand) : []; - const uaData = navigator.userAgentData ? { - brands: uaBrands, - fullVersionList: uaBrands.map(mapFullVersionBrand), - mobile: !!navigator.userAgentData.mobile, - platform: String(navigator.userAgentData.platform || platform), - architecture: '__UA_ARCHITECTURE__' - } : null; - const uaDataJson = JSON.stringify(uaData); - return [ - '(() => {', - ' const defineNavigatorValue = (name, value) => {', - ' try { Object.defineProperty(Object.getPrototypeOf(navigator), name, { get: () => value, configurable: true }); } catch (_) {}', - ' try { Object.defineProperty(navigator, name, { get: () => value, configurable: true }); } catch (_) {}', - ' };', - ` defineNavigatorValue('userAgent', ${JSON.stringify(ua)});`, - ` defineNavigatorValue('appVersion', ${JSON.stringify(appVersion)});`, - ` defineNavigatorValue('platform', ${JSON.stringify(platform)});`, - ` defineNavigatorValue('language', ${JSON.stringify(language)});`, - ` defineNavigatorValue('languages', ${JSON.stringify(languages)});`, - ' try { delete Object.getPrototypeOf(navigator).webdriver; } catch (_) {}', - ' try { delete navigator.webdriver; } catch (_) {}', - ` const uaData = ${uaDataJson};`, - ' if (uaData) {', - ' const data = {', - ' brands: uaData.brands,', - ' mobile: uaData.mobile,', - ' platform: uaData.platform,', - ' getHighEntropyValues: async hints => {', - ' const values = { brands: uaData.brands, mobile: uaData.mobile, platform: uaData.platform };', - ' for (const hint of hints || []) {', - " if (hint === 'fullVersionList') values.fullVersionList = uaData.fullVersionList;", - " if (hint === 'architecture') values.architecture = uaData.architecture;", - " if (hint === 'bitness') values.bitness = '64';", - " if (hint === 'model') values.model = '';", - " if (hint === 'platformVersion') values.platformVersion = '';", - ' }', - ' return values;', - ' },', - ' toJSON: () => ({ brands: uaData.brands, mobile: uaData.mobile, platform: uaData.platform })', - ' };', - " defineNavigatorValue('userAgentData', data);", - ' }', - '})();' - ].join('\n'); - }; - const WrappedWorker = function(scriptURL, options) { - try { - const workerOptions = options || {}; - const absoluteUrl = new URL(String(scriptURL), location.href).href; - const identitySource = makeWorkerIdentitySource(); - const source = workerOptions.type === 'module' - ? `${identitySource}\nimport ${JSON.stringify(absoluteUrl)};` - : `${identitySource}\nimportScripts(${JSON.stringify(absoluteUrl)});`; - const blobUrl = URL.createObjectURL(new Blob([source], { type: 'text/javascript' })); - return new NativeWorker(blobUrl, workerOptions); - } catch (_) { - return new NativeWorker(scriptURL, options); - } - }; - WrappedWorker.prototype = NativeWorker.prototype; - try { Object.setPrototypeOf(WrappedWorker, NativeWorker); } catch (_) {} - try { Object.defineProperty(WrappedWorker, 'name', { value: 'Worker', configurable: true }); } catch (_) {} - try { Object.defineProperty(WrappedWorker, 'toString', { value: () => 'function Worker() { [native code] }', configurable: true }); } catch (_) {} - try { Object.defineProperty(WrappedWorker, workerMarker, { value: true }); } catch (_) {} - Object.defineProperty(window, 'Worker', { - value: WrappedWorker, - configurable: true, - writable: true - }); - } - } catch (_) {} })(); "#; @@ -24807,23 +24888,155 @@ fn parse_js_identifier_prefix(value: &str) -> Option { Some(identifier) } +/// Whether `expression` is itself a function literal, and so should be wrapped and called +/// rather than evaluated for its value. +/// +/// This only inspects the head of the expression. Searching the whole source for `=>` misreads +/// any parenthesised expression that merely *contains* an arrow somewhere inside it — an IIFE +/// such as `(function () { return [1].map((v) => v); })()` has already produced its value, and +/// calling that value fails with "__rw_fn is not a function". fn looks_like_function(expression: &str) -> bool { - expression.starts_with("function") - || expression.starts_with("async function") - || expression - .find("=>") - .map(|index| { - let before_arrow = expression[..index].trim(); - if before_arrow.starts_with('(') { - return true; - } - if let Some(parameter) = before_arrow.strip_prefix("async ") { - let parameter = parameter.trim(); - return parameter.starts_with('(') || is_js_identifier(parameter); + let expression = expression.trim_start(); + if starts_with_keyword(expression, "function") { + return true; + } + if let Some(rest) = strip_keyword(expression, "async") { + return starts_with_keyword(rest, "function") || starts_with_arrow_head(rest); + } + starts_with_arrow_head(expression) +} + +/// Whether `expression` opens with an arrow function's parameter list and the arrow that binds +/// it: either `(params) =>` or a single `identifier =>`. +fn starts_with_arrow_head(expression: &str) -> bool { + if expression.starts_with('(') { + let Some(close) = matching_parenthesis(expression) else { + return false; + }; + return expression[close + 1..].trim_start().starts_with("=>"); + } + let identifier_end = expression + .find(|ch: char| !is_js_identifier_continue(ch)) + .unwrap_or(expression.len()); + let (identifier, rest) = expression.split_at(identifier_end); + is_js_identifier(identifier) && rest.trim_start().starts_with("=>") +} + +/// The byte index of the `)` matching the `(` that `expression` opens with. +/// +/// Parentheses inside string and template literals, comments, and nested groups do not count, +/// or `("(") => 1` and `(a /* ) */) => 1` would be misread. +fn matching_parenthesis(expression: &str) -> Option { + let bytes = expression.as_bytes(); + let mut depth = 0usize; + let mut index = 0usize; + while index < bytes.len() { + match bytes[index] { + b'(' => depth += 1, + b')' => { + depth -= 1; + if depth == 0 { + return Some(index); } - is_js_identifier(before_arrow) - }) - .unwrap_or(false) + } + quote @ (b'\'' | b'"' | b'`') => { + index = skip_string_literal(bytes, index, quote)?; + } + // A `/` that can only begin a value starts a regular expression, and `)` inside one + // does not close the parameter list: `(pattern = /[)]/) => pattern` is a function. + b'/' if regex_literal_can_start_at(bytes, index) => { + index = skip_regex_literal(bytes, index)?; + } + b'/' if bytes.get(index + 1) == Some(&b'/') => { + index = match expression[index..].find('\n') { + Some(offset) => index + offset, + None => return None, + }; + } + b'/' if bytes.get(index + 1) == Some(&b'*') => { + let offset = expression[index + 2..].find("*/")?; + index = index + 2 + offset + 1; + } + _ => {} + } + index += 1; + } + None +} + +/// Whether the `/` at `index` opens a regular expression rather than a comment or a division. +/// +/// The preceding token settles it: a `/` after a value is division, and after `=`, `,`, `(` or +/// `[` it can only open a literal. +fn regex_literal_can_start_at(bytes: &[u8], index: usize) -> bool { + if matches!(bytes.get(index + 1), Some(b'/') | Some(b'*')) { + return false; + } + let mut before = index; + while before > 0 { + before -= 1; + if !bytes[before].is_ascii_whitespace() { + return matches!( + bytes[before], + b'=' | b',' | b'(' | b'[' | b'{' | b'!' | b'&' | b'|' | b'?' | b':' | b';' + ); + } + } + false +} + +/// The byte index of the `/` closing the regular expression starting at `start`. +/// +/// A character class can contain an unescaped `/`, so the scan tracks whether it is inside one. +fn skip_regex_literal(bytes: &[u8], start: usize) -> Option { + let mut index = start + 1; + let mut in_class = false; + while index < bytes.len() { + match bytes[index] { + b'\\' => index += 1, + b'[' => in_class = true, + b']' => in_class = false, + b'\n' => return None, + b'/' if !in_class => return Some(index), + _ => {} + } + index += 1; + } + None +} + +/// The byte index of the closing `quote` for the literal starting at `start`. +/// +/// Template literals are treated as opaque: a `${...}` substitution cannot close the literal, +/// and any parenthesis inside one is already ignored by virtue of being inside it. +fn skip_string_literal(bytes: &[u8], start: usize, quote: u8) -> Option { + let mut index = start + 1; + while index < bytes.len() { + match bytes[index] { + b'\\' => index += 1, + byte if byte == quote => return Some(index), + _ => {} + } + index += 1; + } + None +} + +/// Whether `expression` begins with `keyword` as a whole word rather than as the prefix of a +/// longer identifier, so `functionality()` is not mistaken for a function literal. +fn starts_with_keyword(expression: &str, keyword: &str) -> bool { + expression + .strip_prefix(keyword) + .is_some_and(|rest| !rest.starts_with(is_js_identifier_continue)) +} + +/// `expression` with a leading `keyword` word and the whitespace after it removed. +fn strip_keyword<'a>(expression: &'a str, keyword: &str) -> Option<&'a str> { + let rest = expression.strip_prefix(keyword)?; + if !rest.starts_with(char::is_whitespace) { + return None; + } + Some(rest.trim_start()) } fn is_js_identifier(value: &str) -> bool {