diff --git a/src/apis/nimbus.js b/src/apis/nimbus.js index c4f83d4..d775a0a 100644 --- a/src/apis/nimbus.js +++ b/src/apis/nimbus.js @@ -28,6 +28,18 @@ ChromeUtils.defineLazyGetter(this, "_ExperimentAPI", () => { } }); +ChromeUtils.defineLazyGetter(this, "TargetingContextRecorder", () => { + try { + return ChromeUtils.importESModule( + "moz-src:///toolkit/components/nimbus/lib/TargetingContextRecorder.sys.mjs", + ); + } catch { + return ChromeUtils.importESModule( + "resource://nimbus/lib/TargetingContextRecorder.sys.mjs", + ); + } +}); + ChromeUtils.defineLazyGetter( this, "ExperimentAPI", @@ -296,7 +308,7 @@ var nimbus = class extends ExtensionAPI { ExperimentManager.createTargetingContext(), ); - const targetingParameters = { + const values = { ...environment, ...localContext, // This code is based on the implementation from: @@ -309,7 +321,11 @@ var nimbus = class extends ExtensionAPI { os: ClientEnvironmentBase.os, }; - return targetingParameters; + return { + attrs: Object.keys(TargetingContextRecorder.ATTRIBUTE_TRANSFORMS), + prefs: Object.keys(TargetingContextRecorder.PREFS), + values, + }; }, async updateRecipes() { diff --git a/src/types/global.d.ts b/src/types/global.d.ts index 7506a20..0a6b8be 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -75,7 +75,7 @@ declare module "mozjexl/lib/parser/Parser" { type Identifier = { type: "Identifier"; value: string; - from?: Identifier; + from?: ASTNode; }; type ArrayLiteral = { @@ -136,6 +136,12 @@ type CurrentCollection = { prefValue: string; }; +type ClientContextValue = object | string | boolean | number | Date | undefined; +type ClientContext = { + attrs: string[]; + prefs: string[]; + values: Record; +}; declare namespace browser.experiments.nimbus { function enrollInExperiment( jsonData: object, @@ -155,9 +161,9 @@ declare namespace browser.experiments.nimbus { function setCollection(collectionId: string): Promise; - function evaluateJEXL(expression: string, context: object): Promise; + function evaluateJEXL(expression: string, context: object): Promise; - function getClientContext(): Promise; + function getClientContext(): Promise; function injectInactiveEnrollment( recipe: object, diff --git a/src/ui/components/JEXLDebuggerPage.tsx b/src/ui/components/JEXLDebuggerPage.tsx index af69b29..4b68a47 100644 --- a/src/ui/components/JEXLDebuggerPage.tsx +++ b/src/ui/components/JEXLDebuggerPage.tsx @@ -7,14 +7,24 @@ import { useMemo, useRef, } from "react"; -import { Container, Row, Col, Form, Button } from "react-bootstrap"; +import { + Badge, + Container, + Row, + Col, + Form, + Button, + Tooltip, + OverlayTrigger, + TooltipProps, + Alert, +} from "react-bootstrap"; import { useLocation } from "react-router-dom"; import { useToastsContext } from "../hooks/useToasts"; -import { evaluateJexl } from "../jexlParser"; +import { debugJexl, DebugJexlResult } from "../jexlParser"; import { debounce } from "../utils/functional"; -type ContextValue = object | string | boolean | number | Date; type FieldType = "object" | "string" | "boolean" | "number" | "Date"; type FormDataValue = string | boolean; @@ -30,7 +40,7 @@ type ContextFieldProps = { value: TValue; }; -const getFieldType = (value: ContextValue): FieldType => { +const getFieldType = (value: ClientContextValue): FieldType => { switch (typeof value) { case "string": return "string"; @@ -44,6 +54,8 @@ const getFieldType = (value: ContextValue): FieldType => { } else { return "object"; } + case "undefined": + return "object"; } }; @@ -72,6 +84,7 @@ function ContextField({ value={value as string} onChange={handleChange} className="p-3 w-50 grey-border short-text" + id={`context.${contextKey}`} /> ); @@ -81,7 +94,8 @@ function ContextField({ type="text" value={value as string} onChange={handleChange} - className="p-3 w-50 grey-border short-text" + className="p-3 grey-border short-text" + id={`context.${contextKey}`} /> ); @@ -89,10 +103,16 @@ function ContextField({ return ( + className="short-text d-flex align-items-center" + > + + ); case "Date": @@ -103,6 +123,7 @@ function ContextField({ value={value as string} onChange={handleChange} className="p-3 w-50 grey-border short-text" + id={`context.${contextKey}`} /> ); @@ -112,12 +133,14 @@ function ContextField({ as="textarea" value={value as string} onChange={handleChange} - className="p-3 w-50 grey-border long-text" + className="p-3 grey-border long-text font-monospace" placeholder="null" + id={`context.${contextKey}`} /> ); default: + console.error(`Unexpected fieldType: ${fieldType} for ${contextKey}`); return null; } } @@ -126,73 +149,72 @@ type JEXLDebuggerPageState = { jexlExpression?: string; }; +type EvalState = + | ({ ok: true } & DebugJexlResult) + | { ok: false; error: string }; + const JEXLDebuggerPage: FC = () => { const mounted = useRef(false); - const [originalContext, setOriginalContext] = useState< - Record - >({}); - const [modifiedContext, setModifiedContext] = useState< - Record - >({}); - const [formData, setFormData] = useState>({}); const { state: locationState } = useLocation() as { state: JEXLDebuggerPageState | null; }; + const { addToast } = useToastsContext(); + const [clientContext, setClientContext] = useState(); + const [contextOverrides, setContextOverrides] = useState< + Record + >({}); + const [formData, setFormData] = useState>({}); const [jexlExpression, setJexlExpression] = useState( locationState?.jexlExpression ?? "", ); - const [output, setOutput] = useState(""); - const { addToast } = useToastsContext(); + const [evalResult, setEvalResult] = useState(null); const fetchClientContext = useCallback(async () => { - try { - const context = - (await browser.experiments.nimbus.getClientContext()) as Record< - string, - ContextValue - >; - setOriginalContext(context); - setModifiedContext({}); - setFormData( - Object.fromEntries( - Object.entries(context).map(([key, value]) => { - let formValue: FormDataValue; - const fieldType = getFieldType(value); - - switch (fieldType) { - case "string": - formValue = value as string; - break; - case "boolean": - formValue = value as boolean; - break; - case "number": - formValue = (value as number).toString(); - break; - case "Date": - formValue = (value as Date).toISOString().slice(0, 19); - break; - case "object": - formValue = JSON.stringify(value, null, 2); - break; - } - return [key, formValue]; - }), - ), - ); - } catch (error) { - addToast({ - message: `Error fetching client context: ${(error as Error).message ?? String(error)}`, - variant: "danger", - }); - } + await browser.experiments.nimbus.getClientContext().then( + (context) => { + setClientContext(context); + setContextOverrides({}); + setFormData( + Object.fromEntries( + Object.entries(context.values).map(([key, value]) => { + let formValue: FormDataValue; + const fieldType = getFieldType(value); + + switch (fieldType) { + case "string": + formValue = value as string; + break; + case "boolean": + formValue = value as boolean; + break; + case "number": + formValue = (value as number).toString(); + break; + case "Date": + formValue = (value as Date).toISOString().slice(0, 19); + break; + case "object": + if (typeof value === "undefined") { + formValue = "undefined"; + } else { + formValue = JSON.stringify(value, null, 2); + } + break; + } + return [key, formValue]; + }), + ), + ); + }, + (error) => + addToast({ + message: `Error fetching client context: ${(error as Error).message ?? String(error)}`, + variant: "danger", + }), + ); }, [addToast]); useEffect(() => { - // This will raise a false positive about calling setState in effect, even - // though the setState happens *after* an await statement. - // See-also: https://github.com/facebook/react/issues/34905 - // eslint-disable-next-line react-hooks/set-state-in-effect void fetchClientContext(); }, [fetchClientContext]); @@ -204,27 +226,26 @@ const JEXLDebuggerPage: FC = () => { ); const evaluateExpression = useCallback(() => { - evaluateJexl(jexlExpression, { - ...originalContext, - ...modifiedContext, + debugJexl(jexlExpression, { + ...clientContext?.values, + ...contextOverrides, }).then( - (result) => setOutput(result), + (result) => setEvalResult({ ...result, ok: true }), (error) => { - setOutput("Evaluation error"); - addToast({ - message: `Error evaluating expression: ${(error as Error).message ?? String(error)}`, - variant: "danger", + setEvalResult({ + error: error instanceof Error ? error.message : String(error), + ok: false, }); }, ); - }, [jexlExpression, modifiedContext, originalContext, addToast]); + }, [jexlExpression, contextOverrides, clientContext]); const parseAndSetContext = useMemo(() => { return debounce((key: string, value: string, isNumber: boolean) => { if (isNumber) { const numVal = parseInt(value); if (!isNaN(numVal)) { - setModifiedContext((prevContext) => ({ + setContextOverrides((prevContext) => ({ ...prevContext, [key]: numVal, })); @@ -236,8 +257,11 @@ const JEXLDebuggerPage: FC = () => { } } else { try { - const parsedValue = JSON.parse(value) as ContextValue; - setModifiedContext((prevContext) => ({ + const parsedValue = + value.trim() === "undefined" + ? undefined + : (JSON.parse(value) as ClientContextValue); + setContextOverrides((prevContext) => ({ ...prevContext, [key]: parsedValue, })); @@ -263,21 +287,21 @@ const JEXLDebuggerPage: FC = () => { break; case "boolean": - setModifiedContext((prevContext) => ({ + setContextOverrides((prevContext) => ({ ...prevContext, [key]: value, })); break; case "Date": - setModifiedContext((prevContext) => ({ + setContextOverrides((prevContext) => ({ ...prevContext, [key]: new Date(value as string), })); break; case "string": - setModifiedContext((prevContext) => ({ + setContextOverrides((prevContext) => ({ ...prevContext, [key]: value, })); @@ -299,6 +323,30 @@ const JEXLDebuggerPage: FC = () => { mounted.current = true; }, [locationState?.jexlExpression, evaluateExpression]); + const { unreportedAttrs, unreportedPrefs } = useMemo(() => { + const unreportedAttrs: string[] = []; + const unreportedPrefs: string[] = []; + + if (evalResult?.ok && clientContext) { + unreportedAttrs.push( + ...Array.from(evalResult.attrs.values()).filter( + (attr) => !clientContext.attrs.includes(attr), + ), + ); + + unreportedPrefs.push( + ...Array.from(evalResult.prefs.values()).filter( + (pref) => !clientContext.prefs.includes(pref), + ), + ); + } + + return { + unreportedAttrs: unreportedAttrs.length ? unreportedAttrs : null, + unreportedPrefs: unreportedPrefs.length ? unreportedPrefs : null, + }; + }, [evalResult, clientContext]); + return ( @@ -323,8 +371,65 @@ const JEXLDebuggerPage: FC = () => {
+ {unreportedAttrs && ( + + + Targeting Expression includes Unreported Attributes + +

+ This targeting expression includes attributes that are not + reported in Nimbus targeting context telemetry and are not + indexed by Experimenter: +

+
    + {unreportedAttrs.map((attr) => ( +
  • + {attr} +
  • + ))} +
+
+ )} + {unreportedPrefs && ( + + + Targeting Expression includes Unreported Prefs + +

+ This targeting expression includes prefs that are not reported + in Nimbus targeting context telemetry and are not indexed by + Experimenter: +

+
    + {unreportedPrefs.map((pref) => ( +
  • + {pref} +
  • + ))} +
+
+ )}

Output

-
{output}
+ {evalResult ? ( + evalResult.ok ? ( +
{evalResult.value}
+ ) : ( +

Could not evaluate JEXL: {evalResult.error}

+ ) + ) : null} + {evalResult?.ok && evalResult.falseExprs.length > 0 ? ( + <> +

False sub-expressions

+

The following sub-expressions evaluated to false:

+
    + {evalResult.falseExprs.map((value, i) => ( +
  • + {value} +
  • + ))} +
+ + ) : null}

@@ -337,25 +442,56 @@ const JEXLDebuggerPage: FC = () => { > Reset Context - {Object.entries(originalContext).map(([key]) => ( - - - {key} - - - - - - ))} + {clientContext && + Object.entries(clientContext.values).map(([key]) => ( + + + + {key} + + {clientContext.attrs.includes(key) || ( + + + + ! + + + + )} + + + + + + ))}
); }; +function NotInMetricsWarning(props: TooltipProps): JSX.Element { + return ( + + This attribute is not reported in Nimbus targeting context telemetry and + is not indexed by Experimenter. + + ); +} + export default JEXLDebuggerPage; diff --git a/src/ui/index.css b/src/ui/index.css index 6e7b7c3..0de8be5 100644 --- a/src/ui/index.css +++ b/src/ui/index.css @@ -92,7 +92,8 @@ html { } .large-checkbox { - transform: scale(1.5); + height: 2em; + width: 2em; } .checkbox-border { diff --git a/src/ui/jexlParser.ts b/src/ui/jexlParser.ts index e3b1287..1d6fe6d 100644 --- a/src/ui/jexlParser.ts +++ b/src/ui/jexlParser.ts @@ -19,142 +19,170 @@ const grammar = { }, }; +type DebugContext = { + falseExprs: string[]; + attrs: Set; + prefs: Set; +}; + +export type DebugJexlResult = DebugContext & { + value: string; +}; + /** * Evaluates a JEXL expression within a given context. - * @param {string} expression - The JEXL expression to evaluate. - * @param {object} context - The context in which to evaluate the expression. - * @returns {Promise} - The result of the evaluation as a string. + * + * @param expression The JEXL expression to evaluate. + * @param context The context in which to evaluate the expression. + * + * @returns The result of the evaluation as a string. */ -export async function evaluateJexl( +export async function debugJexl( expression: string, context: object = {}, -): Promise { +): Promise { if (!expression.trim()) { throw new Error("Empty expression"); } + const value = await evaluateExpression(expression, context); + const lexer = new Lexer(grammar); const parser = new Parser(grammar); parser.addTokens(lexer.tokenize(expression)); + + const debugCtx = { + falseExprs: [], + attrs: new Set(), + prefs: new Set(), + } as DebugContext; + const ast = parser.complete(); - const falseParts: string[] = []; - await traverseAst(ast, context, falseParts); + await collectFalseExprs(ast, context, debugCtx.falseExprs); + collectAttrsAndPrefs(ast, debugCtx); - const finalResult = await evaluateExpression(expression, context); - const falsePartsStr = !finalResult - ? `\n\nFalse Parts:\n${falseParts.join("\n")}` - : ""; - return `${JSON.stringify(finalResult, null, 2)}${falsePartsStr}`; + return { + ...debugCtx, + value: + typeof value === "undefined" + ? "undefined" + : typeof value === "string" + ? quoteString(value) + : JSON.stringify(value, null, 2), + }; } /** - * Evaluates a JEXL expression using the browser's Nimbus API. - * @param {string} expression - The JEXL expression to evaluate. - * @param {object} context - The context in which to evaluate the expression. - * @returns {Promise} - The result of the evaluation. + * Debug a JEXL expression using the browser's Nimbus API. + * + * @param expression - The JEXL expression to evaluate. + * @param context - The context in which to evaluate the expression. + * + * @returns Debugging information about the expression. */ async function evaluateExpression( expression: string, context: object, -): Promise { +): Promise { try { return await browser.experiments.nimbus.evaluateJEXL(expression, context); } catch (error) { - console.error(`Error evaluating part "${expression}":`, error); + console.error( + `Error evaluating expression ${quoteString(expression)}:`, + error, + ); throw error; } } /** * Traverses the AST to evaluate sub-expressions and collect parts that evaluate to false. - * @param {ASTNode} ast - The AST node to traverse. - * @param {object} context - The context in which to evaluate the expressions. - * @param {string[]} falseParts - An array to collect expressions that evaluate to false. - * @returns {Promise} - The result of the traversal. + * + * @param ast The AST node to traverse. + * @param context The context in which to evaluate the expressions. + * @param falseExprs An array to collect expressions that evaluate to false. + * + * @returns The result of the traversal. */ -async function traverseAst( +async function collectFalseExprs( ast: ASTNode, context: object, - falseParts: string[], + falseExprs: string[], ): Promise { - if (!ast) { - return true; - } - - const subExpr = await getExpression(ast); + const subExpr = getExpression(ast); const result = await evaluateExpression(subExpr, context); if (ast.type === "BinaryExpression" || ast.type === "LogicalExpression") { const leftResult = await evaluateExpression( - await getExpression(ast.left), + getExpression(ast.left), context, ); const rightResult = await evaluateExpression( - await getExpression(ast.right), + getExpression(ast.right), context, ); if (result === false && leftResult !== false && rightResult !== false) { - falseParts.push(subExpr); + falseExprs.push(subExpr); } else { if (ast.left) { - await traverseAst(ast.left, context, falseParts); + await collectFalseExprs(ast.left, context, falseExprs); } if (ast.right) { - await traverseAst(ast.right, context, falseParts); + await collectFalseExprs(ast.right, context, falseExprs); } } } else if (ast.type === "UnaryExpression") { const rightResult = await evaluateExpression( - await getExpression(ast.right), + getExpression(ast.right), context, ); if (result === false && rightResult !== false) { - falseParts.push(subExpr); + falseExprs.push(subExpr); if (ast.right) { - await traverseAst(ast.right, context, falseParts); + await collectFalseExprs(ast.right, context, falseExprs); } } else { if (ast.right) { - await traverseAst(ast.right, context, falseParts); + await collectFalseExprs(ast.right, context, falseExprs); } } } else if (ast.type === "Transform") { if (result === false) { - falseParts.push(subExpr); + falseExprs.push(subExpr); } else { if (ast.subject) { - await traverseAst(ast.subject, context, falseParts); + await collectFalseExprs(ast.subject, context, falseExprs); } if (ast.args) { for (const arg of ast.args) { - await traverseAst(arg, context, falseParts); + await collectFalseExprs(arg, context, falseExprs); } } } } else if (ast.type === "FilterExpression") { if (result === false) { - falseParts.push(subExpr); + falseExprs.push(subExpr); } else { if (ast.subject) { - await traverseAst(ast.subject, context, falseParts); + await collectFalseExprs(ast.subject, context, falseExprs); } if (ast.expr) { - await traverseAst(ast.expr, context, falseParts); + await collectFalseExprs(ast.expr, context, falseExprs); } } } else if (ast.type === "Literal" || ast.type === "Identifier") { if (result === false) { - falseParts.push(subExpr); + falseExprs.push(subExpr); } } else if (ast.type === "ObjectLiteral") { if (result === false) { - falseParts.push(subExpr); + falseExprs.push(subExpr); } else { for (const key in ast.value) { - await traverseAst(ast.value[key], context, falseParts); + await collectFalseExprs(ast.value[key], context, falseExprs); } } } @@ -162,31 +190,105 @@ async function traverseAst( return result; } +function collectAttrsAndPrefs(ast: ASTNode, debugCtx: DebugContext) { + switch (ast.type) { + case "BinaryExpression": + case "LogicalExpression": + collectAttrsAndPrefs(ast.left, debugCtx); + collectAttrsAndPrefs(ast.right, debugCtx); + break; + + case "UnaryExpression": + collectAttrsAndPrefs(ast.right, debugCtx); + break; + + case "Transform": + if ( + ast.name === "preferenceValue" && + ast.subject.type === "Literal" && + typeof ast.subject.value === "string" + ) { + debugCtx.prefs.add(ast.subject.value); + } else { + collectAttrsAndPrefs(ast.subject, debugCtx); + } + break; + + case "FilterExpression": + collectAttrsAndPrefs(ast.expr, debugCtx); + collectAttrsAndPrefs(ast.subject, debugCtx); + break; + + case "Identifier": + { + const attr = getRootAttribute(ast); + if (attr) { + debugCtx.attrs.add(attr); + } + } + break; + + case "ObjectLiteral": + for (const valueNode of Object.values(ast.value)) { + collectAttrsAndPrefs(valueNode, debugCtx); + } + break; + + case "ArrayLiteral": + for (const itemNode of ast.value) { + collectAttrsAndPrefs(itemNode, debugCtx); + } + break; + + case "Literal": + break; + + default: + // TypeScript correctly deduces `ast: never`, so we have to re-cast it to an + // ASTNode to print this error. + throw new TypeError(`Unexpected AST node type ${(ast as ASTNode).type}`); + } +} + /** - * Retrieves the full identifier string from an AST node. - * @param {Identifier} ast - The AST node representing an identifier. - * @returns {string} - The full identifier string. - */ -function getFullIdentifier(ast: Identifier): string { + * Return the root attribute of a given indentifier, if it exists. + * + * For example, for the identifier "foo.bar.baz", this will return "foo". + * + * However, for an identifier on, e.g., an object or array literal, this will + * return null. + * + * @param ast The AST node for the identifier. + * + * @returns The root identifier + * */ +function getRootAttribute(ast: Identifier): string | null { if (!ast.from) { return ast.value; } - return `${getFullIdentifier(ast.from)}.${ast.value}`; + + if (ast.from.type === "Identifier") { + return getRootAttribute(ast.from); + } + + return null; } /** * Converts an AST node to its corresponding JEXL expression string. - * @param {ASTNode} ast - The AST node to convert. - * @returns {Promise} - The JEXL expression string. + * + * @param ast The AST node to convert. + * + * @returns The JEXL expression string. */ -async function getExpression(ast: ASTNode): Promise { +function getExpression(ast: ASTNode): string { if (!ast) { return ""; } if (ast.type === "BinaryExpression" || ast.type === "LogicalExpression") { - const leftExpr = await getExpression(ast.left); - const rightExpr = await getExpression(ast.right); + const leftExpr = getExpression(ast.left); + const rightExpr = getExpression(ast.right); const leftWrapped = ast.left.type === "Literal" || ast.left.type === "Identifier" || @@ -201,48 +303,70 @@ async function getExpression(ast: ASTNode): Promise { : `(${rightExpr})`; return `${leftWrapped} ${ast.operator} ${rightWrapped}`; } else if (ast.type === "UnaryExpression") { - const rightExpr = await getExpression(ast.right); + const rightExpr = getExpression(ast.right); return `${ast.operator}(${rightExpr})`; } else if (ast.type === "Transform") { - const subjectExpr = await getExpression(ast.subject); - const argsExpr = ast.args - ? (await Promise.all(ast.args.map(getExpression))).join(", ") - : ""; + const subjectExpr = getExpression(ast.subject); + const argsExpr = ast.args ? ast.args.map(getExpression).join(", ") : ""; return argsExpr.length === 0 ? `${subjectExpr}|${ast.name}` : `${subjectExpr}|${ast.name}(${argsExpr})`; } else if (ast.type === "FilterExpression") { - const subjectExpr = await getExpression(ast.subject); - const filterExpr = await getExpression(ast.expr); + const subjectExpr = getExpression(ast.subject); + const filterExpr = getExpression(ast.expr); return `${subjectExpr}[${filterExpr}]`; } else if (ast.type === "Literal") { - return typeof ast.value === "string" ? `'${ast.value}'` : `${ast.value}`; + return typeof ast.value === "string" + ? quoteString(ast.value) + : `${ast.value}`; } else if (ast.type === "Identifier") { - return getFullIdentifier(ast); + if (ast.from) { + return getExpression(ast.from) + "." + ast.value; + } + return ast.value; } else if (ast.type === "ArrayLiteral") { - const elementsExpr = (await Promise.all(ast.value.map(getExpression))).join( - ", ", - ); + const elementsExpr = ast.value.map(getExpression).join(", "); return `[${elementsExpr}]`; } else if (ast.type === "ObjectLiteral") { const entries = Object.entries(ast.value); - const objectExpr = await Promise.all( - entries.map(async ([key, value]) => { - const valueExpr = await getExpression(value); - if (value.type === "ObjectLiteral") { - const nestedEntries = Object.entries(value.value); - const nestedObjectExpr = await Promise.all( - nestedEntries.map(async ([nestedKey, nestedValue]) => { - const nestedValueExpr = await getExpression(nestedValue); - return `${nestedKey}: ${nestedValueExpr}`; - }), - ); - return `${key}: { ${nestedObjectExpr.join(", ")} }`; - } - return `${key}: ${valueExpr}`; - }), - ); + const objectExpr = entries.map(([key, value]) => { + const valueExpr = getExpression(value); + return `${key}: ${valueExpr}`; + }); return `{ ${objectExpr.join(", ")} }`; } return ""; } + +/** + * Quote a string so that it is re-parseable by mozjexl. + * + * @param s The string to quote. + * + * @returns A quoted version of the given string. + */ +function quoteString(s: string) { + // If the string does not include both types of quotes, it is easy to re-quote. + if (!s.includes(`"`)) { + return `"${s}"`; + } + + if (!s.includes(`'`)) { + return `'${s}'`; + } + + // However, if it does contain both types of quotes, then we must pick one and + // escape every unescaped instance of that quote inside `s`. + // + // A quote is unescaped if it is preceded by an even number of backslashes + // (including 0). + + // The mozjexl lexer does not accept a string of the form `"\""`, so we need + // to escape all the single quotes within s and surround it with single + // quotes. + if (s.endsWith(`"`)) { + return `'` + s.replaceAll(/(\\\\)*'/g, `$1\\'`) + `'`; + } + + return `"` + s.replaceAll(/(\\\\)*"/g, `$1\\"`) + `"`; +}