diff --git a/packages/code-export-csharp-commons/src/command.ts b/packages/code-export-csharp-commons/src/command.ts index 609bb423dd..1b57da6386 100644 --- a/packages/code-export-csharp-commons/src/command.ts +++ b/packages/code-export-csharp-commons/src/command.ts @@ -141,9 +141,16 @@ function emit(command: CommandShape, context: EmitterContext) { context, emitNewWindowHandling, variableLookup, + variableInString, }) } +// How a variable is joined with a string literal, e.g. "Hi " + name +const variableInString = { + quotes: ['"'], + toString: (lookup: string) => lookup, +} + function variableLookup(varName: string) { return `vars["${varName}"].ToString()` } @@ -400,8 +407,7 @@ async function emitDragAndDrop(dragged: any, dropped: any) { } async function emitEcho(message: string) { - const _message = message.startsWith('vars[') ? message : `"${message}"` - return Promise.resolve(`Console.WriteLine(${_message});`) + return Promise.resolve(`Console.WriteLine("${message}");`) } async function emitEditContent(locator: any, content: any) { @@ -645,9 +651,7 @@ function generateSendKeysInput(value: string | string[]) { if (Array.isArray(value)) { return value .map((s) => { - if (s.startsWith('vars[')) { - return s - } else if (s.startsWith('Key[')) { + if (s.startsWith('Key[')) { const key = (s.match(/\['(.*)'\]/) as RegExpMatchArray)[1] return `Keys.${exporter.parsers.capitalize(key.toLowerCase())}` } else { @@ -1092,6 +1096,7 @@ async function emitWaitForElementNotVisible(locator: any, timeout: number) { export default { emitters, variableLookup, + variableInString, variableSetter, emit, register, diff --git a/packages/code-export-csharp-commons/src/index.ts b/packages/code-export-csharp-commons/src/index.ts index 57e46c7dad..70daef5aec 100644 --- a/packages/code-export-csharp-commons/src/index.ts +++ b/packages/code-export-csharp-commons/src/index.ts @@ -15,7 +15,6 @@ // specific language governing permissions and limitations // under the License. - import Command from './command' import location from './location' @@ -30,6 +29,6 @@ const exportFormat = { }, } as const -export default exportFormat; -export {default as Command} from './command' -export {default as location} from './location' +export default exportFormat +export { default as Command } from './command' +export { default as location } from './location' diff --git a/packages/code-export-csharp-nunit/src/index.ts b/packages/code-export-csharp-nunit/src/index.ts index c4778235be..2e5c4c4fa7 100644 --- a/packages/code-export-csharp-nunit/src/index.ts +++ b/packages/code-export-csharp-nunit/src/index.ts @@ -58,4 +58,4 @@ export const opts: LanguageEmitterOpts = { } // Emit an individual test, wrapped in a suite (using the test name as the suite name) -export default languageFromOpts(opts, location.emit); +export default languageFromOpts(opts, location.emit) diff --git a/packages/code-export-csharp-xunit/src/command.ts b/packages/code-export-csharp-xunit/src/command.ts index 62f5cac9cf..906d8b0fad 100644 --- a/packages/code-export-csharp-xunit/src/command.ts +++ b/packages/code-export-csharp-xunit/src/command.ts @@ -29,6 +29,7 @@ function emit(command: CommandShape, context: EmitterContext) { context, emitNewWindowHandling: Command.extras.emitNewWindowHandling, variableLookup: Command.variableLookup, + variableInString: Command.variableInString, }) } diff --git a/packages/code-export-csharp-xunit/src/hook.ts b/packages/code-export-csharp-xunit/src/hook.ts index a2a3db1fe8..3c642645b1 100644 --- a/packages/code-export-csharp-xunit/src/hook.ts +++ b/packages/code-export-csharp-xunit/src/hook.ts @@ -29,7 +29,7 @@ export const emitters = { inEachEnd: empty, } as const -export default emitters; +export default emitters function beforeEach() { const params: HookFunctionInputs = { diff --git a/packages/code-export-java-junit/src/command.ts b/packages/code-export-java-junit/src/command.ts index 7dcc39a6b9..71d88ddaa1 100644 --- a/packages/code-export-java-junit/src/command.ts +++ b/packages/code-export-java-junit/src/command.ts @@ -140,10 +140,17 @@ function emit(command: CommandShape, context: EmitterContext) { return exporter.emit.command(command, emitters[command.command], { context, variableLookup, + variableInString, emitNewWindowHandling, }) } +// How a variable is joined with a string literal, e.g. "Hi " + name +const variableInString = { + quotes: ['"'], + toString: (lookup: string) => lookup, +} + function variableLookup(varName: string) { return `vars.get("${varName}").toString()` } @@ -389,8 +396,7 @@ async function emitDragAndDrop(dragged: string, dropped: string) { } async function emitEcho(message: string) { - const _message = message.startsWith('vars.get') ? message : `"${message}"` - return Promise.resolve(`System.out.println(${_message});`) + return Promise.resolve(`System.out.println("${message}");`) } async function emitEditContent(locator: string, content: string) { @@ -632,9 +638,7 @@ function generateSendKeysInput(value: string | string[]) { if (typeof value === 'object') { return value .map((s) => { - if (s.startsWith('vars.get')) { - return s - } else if (s.startsWith('Key[')) { + if (s.startsWith('Key[')) { const key = s.match(/\['(.*)'\]/)?.[1] as string return `Keys.${key}` } else { diff --git a/packages/code-export-java-junit/src/hook.ts b/packages/code-export-java-junit/src/hook.ts index ee51387dc3..726f2aa1b7 100644 --- a/packages/code-export-java-junit/src/hook.ts +++ b/packages/code-export-java-junit/src/hook.ts @@ -15,7 +15,7 @@ // specific language governing permissions and limitations // under the License. -import { HookFunctionInputs } from "side-code-export" +import { HookFunctionInputs } from 'side-code-export' const emitters = { afterAll, diff --git a/packages/code-export-javascript-mocha/__test__/src/__snapshots__/command.spec.js.snap b/packages/code-export-javascript-mocha/__test__/src/__snapshots__/command.spec.js.snap index e7b7cd2c45..bb2e33e63c 100644 --- a/packages/code-export-javascript-mocha/__test__/src/__snapshots__/command.spec.js.snap +++ b/packages/code-export-javascript-mocha/__test__/src/__snapshots__/command.spec.js.snap @@ -153,7 +153,7 @@ exports[`command code emitter should emit \`drag and drop to object\` command 1` exports[`command code emitter should emit \`echo\` command 1`] = `"console.log(\\"blah\\")"`; -exports[`command code emitter should emit \`echo\` command with variables 1`] = `"console.log(vars[\\"blah\\"])"`; +exports[`command code emitter should emit \`echo\` command with variables 1`] = `"console.log(String(vars[\\"blah\\"]))"`; exports[`command code emitter should emit \`edit content\` command 1`] = ` "{ @@ -299,7 +299,7 @@ exports[`command code emitter should emit \`send keys\` command 1`] = `"await dr exports[`command code emitter should emit \`send keys\` command with a key press 1`] = `"await driver.findElement(By.id(\\"input\\")).sendKeys(\\"SuperSecretPassword!\\", Key.ENTER)"`; -exports[`command code emitter should emit \`send keys\` command with a variable input 1`] = `"await driver.findElement(By.id(\\"input\\")).sendKeys(vars[\\"blah\\"])"`; +exports[`command code emitter should emit \`send keys\` command with a variable input 1`] = `"await driver.findElement(By.id(\\"input\\")).sendKeys(String(vars[\\"blah\\"]))"`; exports[`command code emitter should emit \`set speed\` 1`] = `"console.log(\\"\`set speed\` is a no-op in code export, use \`pause\` instead\\")"`; diff --git a/packages/code-export-javascript-mocha/src/command.ts b/packages/code-export-javascript-mocha/src/command.ts index a3ded83e63..aa6ffcdeb1 100644 --- a/packages/code-export-javascript-mocha/src/command.ts +++ b/packages/code-export-javascript-mocha/src/command.ts @@ -140,10 +140,17 @@ function emit(command: CommandShape, context: EmitterContext) { return exporter.emit.command(command, emitters[command.command], { context, variableLookup, + variableInString, emitNewWindowHandling, }) } +// How a variable is joined with a string literal, e.g. "Hi " + name +const variableInString = { + quotes: ['"', "'", '`'], + toString: (lookup: string) => `String(${lookup})`, +} + function variableLookup(varName: string) { return `vars["${varName}"]` } @@ -417,8 +424,7 @@ async function emitDragAndDrop(dragged: string, dropped: string) { } async function emitEcho(message: string) { - const _message = message.startsWith('vars[') ? message : `"${message}"` - return Promise.resolve(`console.log(${_message})`) + return Promise.resolve(`console.log("${message}")`) } async function emitEditContent(locator: string, content: string) { @@ -647,9 +653,7 @@ function generateSendKeysInput(value: string | string[]) { if (typeof value === 'object') { return value .map((s) => { - if (s.startsWith('vars[')) { - return s - } else if (s.startsWith('Key[')) { + if (s.startsWith('Key[')) { const key = s.match(/\['(.*)'\]/)?.[1] return `Key.${key}` } else { diff --git a/packages/code-export-javascript-mocha/src/hook.ts b/packages/code-export-javascript-mocha/src/hook.ts index 868cd6e0cc..f692280c8f 100644 --- a/packages/code-export-javascript-mocha/src/hook.ts +++ b/packages/code-export-javascript-mocha/src/hook.ts @@ -14,7 +14,7 @@ // KIND, either express or implied. See the License for the specific language governing permissions and limitations // under the License. -import { HookFunctionInputs } from "side-code-export" +import { HookFunctionInputs } from 'side-code-export' const emitters = { afterAll, diff --git a/packages/code-export-python-pytest/__test__/src/__snapshots__/command.spec.js.snap b/packages/code-export-python-pytest/__test__/src/__snapshots__/command.spec.js.snap index 5f271ef7f1..a1500c777c 100644 --- a/packages/code-export-python-pytest/__test__/src/__snapshots__/command.spec.js.snap +++ b/packages/code-export-python-pytest/__test__/src/__snapshots__/command.spec.js.snap @@ -125,7 +125,7 @@ actions.drag_and_drop(dragged, dropped).perform()" exports[`command code emitter should emit \`echo\` command 1`] = `"print(\\"blah\\")"`; -exports[`command code emitter should emit \`echo\` command with variables 1`] = `"print(\\"blah = {}\\".format(self.vars[\\"blah\\"]))"`; +exports[`command code emitter should emit \`echo\` command with variables 1`] = `"print(\\"blah = \\" + str(self.vars[\\"blah\\"]))"`; exports[`command code emitter should emit \`edit content\` command 1`] = ` "element = self.driver.find_element(By.ID, \\"contentEditable\\") @@ -257,7 +257,7 @@ exports[`command code emitter should emit \`send keys\` command 1`] = `"self.dri exports[`command code emitter should emit \`send keys\` command with a key press 1`] = `"self.driver.find_element(By.ID, \\"input\\").send_keys(\\"SuperSecretPassword!\\", Keys.ENTER)"`; -exports[`command code emitter should emit \`send keys\` command with a variable input 1`] = `"self.driver.find_element(By.ID, \\"input\\").send_keys(self.vars[\\"blah\\"])"`; +exports[`command code emitter should emit \`send keys\` command with a variable input 1`] = `"self.driver.find_element(By.ID, \\"input\\").send_keys(str(self.vars[\\"blah\\"]))"`; exports[`command code emitter should emit \`set speed\` 1`] = `"print(\\"\`set speed\` is a no-op in code export, use \`pause\` instead\\")"`; diff --git a/packages/code-export-python-pytest/__test__/src/selection.spec.js b/packages/code-export-python-pytest/__test__/src/selection.spec.js index 3037310ec1..a9c6c28b91 100644 --- a/packages/code-export-python-pytest/__test__/src/selection.spec.js +++ b/packages/code-export-python-pytest/__test__/src/selection.spec.js @@ -15,6 +15,7 @@ // specific language governing permissions and limitations // under the License. +import { defaultPreprocessor, resolveVariables } from 'side-code-export' import { emit } from '../../src/selection' describe('selection location code emitter', () => { @@ -35,12 +36,14 @@ describe('selection location code emitter', () => { `By.XPATH, "//option[. = '${selector}']"` ) }) - it('should emit label locator with variable', () => { - const type = 'label' - const selector = 'self.vars["label"]' - return expect(emit(`${type}=${selector}`)).resolves.toEqual( - `By.XPATH, "//option[. = '{}']".format(${selector})` - ) + it('should emit label locator with variable', async () => { + const code = await emit(defaultPreprocessor('label=${label}')) + expect( + resolveVariables(code, (name) => `self.vars["${name}"]`, { + quotes: ['"', "'"], + toString: (lookup) => `str(${lookup})`, + }) + ).toEqual(`By.XPATH, "//option[. = '" + str(self.vars["label"]) + "']"`) }) it('should emit id locator', () => { const type = 'id' diff --git a/packages/code-export-python-pytest/src/command.ts b/packages/code-export-python-pytest/src/command.ts index a0e0ebbcdd..8e438c39da 100644 --- a/packages/code-export-python-pytest/src/command.ts +++ b/packages/code-export-python-pytest/src/command.ts @@ -140,10 +140,17 @@ function emit(command: CommandShape, context: EmitterContext) { return exporter.emit.command(command, emitters[command.command], { context, variableLookup, + variableInString, emitNewWindowHandling, }) } +// How a variable is joined with a string literal, e.g. "Hi " + name +const variableInString = { + quotes: ['"', "'"], + toString: (lookup: string) => `str(${lookup})`, +} + function variableLookup(varName: string) { return `self.vars["${varName}"]` } @@ -394,21 +401,8 @@ async function emitDragAndDrop(dragged: string, dropped: string) { return Promise.resolve({ commands }) } -function translateToPythonString(str: string) { - const regExp = /self.vars\["\w+"]/ - const vars = str.match(regExp) - - if (vars && vars.length > 0) { - const fmtStr = str.replace(regExp, '{}') - return `"${fmtStr}".format(${vars.join(', ')})` - } - - return `"${str}"` -} - async function emitEcho(message: string) { - const _message = translateToPythonString(message) - return Promise.resolve(`print(${_message})`) + return Promise.resolve(`print("${message}")`) } async function emitEditContent(locator: string, content: string) { @@ -616,9 +610,7 @@ function generateSendKeysInput(value: string | string[]) { if (typeof value === 'object') { return value .map((s) => { - if (s.startsWith('self.vars[')) { - return s - } else if (s.startsWith('Key[')) { + if (s.startsWith('Key[')) { const key = s.match(/\['(.*)'\]/)?.[1] return `Keys.${key}` } else { diff --git a/packages/code-export-python-pytest/src/hook.ts b/packages/code-export-python-pytest/src/hook.ts index 8c2c8df398..3bf5c212bc 100644 --- a/packages/code-export-python-pytest/src/hook.ts +++ b/packages/code-export-python-pytest/src/hook.ts @@ -14,7 +14,7 @@ // KIND, either express or implied. See the License for the specific language governing permissions and limitations // under the License. -import { HookFunctionInputs } from "side-code-export" +import { HookFunctionInputs } from 'side-code-export' const emitters = { afterAll, diff --git a/packages/code-export-python-pytest/src/selection.ts b/packages/code-export-python-pytest/src/selection.ts index b84a7ca971..4f529eee6d 100644 --- a/packages/code-export-python-pytest/src/selection.ts +++ b/packages/code-export-python-pytest/src/selection.ts @@ -41,9 +41,6 @@ function emitValue(value: string) { } function emitLabel(label: string) { - if (label.startsWith('self.vars[')) { - return Promise.resolve(`By.XPATH, "//option[. = '{}']".format(${label})`) - } return Promise.resolve(`By.XPATH, "//option[. = '${label}']"`) } diff --git a/packages/code-export-ruby-rspec/__test__/src/__snapshots__/command.spec.js.snap b/packages/code-export-ruby-rspec/__test__/src/__snapshots__/command.spec.js.snap index 2c4617b6ae..069f5b5cc3 100644 --- a/packages/code-export-ruby-rspec/__test__/src/__snapshots__/command.spec.js.snap +++ b/packages/code-export-ruby-rspec/__test__/src/__snapshots__/command.spec.js.snap @@ -121,7 +121,7 @@ dropped = @driver.find_element(:link_text, 'dropped') exports[`command code emitter should emit \`echo\` command 1`] = `"puts('blah')"`; -exports[`command code emitter should emit \`echo\` command with variables 1`] = `"puts(@vars['blah'])"`; +exports[`command code emitter should emit \`echo\` command with variables 1`] = `"puts(@vars['blah'].to_s)"`; exports[`command code emitter should emit \`edit content\` command 1`] = ` "element = @driver.find_element(:id, 'contentEditable') @@ -249,7 +249,7 @@ exports[`command code emitter should emit \`send keys\` command 1`] = `"@driver. exports[`command code emitter should emit \`send keys\` command with a key press 1`] = `"@driver.find_element(:id, 'input').send_keys('SuperSecretPassword!', :enter)"`; -exports[`command code emitter should emit \`send keys\` command with a variable input 1`] = `"@driver.find_element(:id, 'input').send_keys(@vars['blah'])"`; +exports[`command code emitter should emit \`send keys\` command with a variable input 1`] = `"@driver.find_element(:id, 'input').send_keys(@vars['blah'].to_s)"`; exports[`command code emitter should emit \`set speed\` 1`] = `"print(\\"\`set speed\` is a no-op in code export, use \`pause\` instead\\")"`; diff --git a/packages/code-export-ruby-rspec/src/command.ts b/packages/code-export-ruby-rspec/src/command.ts index 01ac79d2cf..8ac2363868 100644 --- a/packages/code-export-ruby-rspec/src/command.ts +++ b/packages/code-export-ruby-rspec/src/command.ts @@ -139,10 +139,17 @@ function emit(command: CommandShape, context: EmitterContext) { return exporter.emit.command(command, emitters[command.command], { context, variableLookup, + variableInString, emitNewWindowHandling, }) } +// How a variable is joined with a string literal, e.g. "Hi " + name +const variableInString = { + quotes: ['"', "'"], + toString: (lookup: string) => `${lookup}.to_s`, +} + function variableLookup(varName: string) { return `@vars['${varName}']` } @@ -387,8 +394,7 @@ async function emitDragAndDrop(dragged: string, dropped: string) { } async function emitEcho(message: string) { - const _message = message.startsWith('@vars[') ? message : `'${message}'` - return Promise.resolve(`puts(${_message})`) + return Promise.resolve(`puts('${message}')`) } async function emitEditContent(locator: string, content: string) { @@ -592,9 +598,7 @@ function generateSendKeysInput(value: string | string[]) { if (typeof value === 'object') { return value .map((s) => { - if (s.startsWith('@vars[')) { - return s - } else if (s.startsWith('Key[')) { + if (s.startsWith('Key[')) { const key = s.match(/\['(.*)'\]/)?.[1] return `:${key?.toLowerCase()}` } else { diff --git a/packages/code-export-ruby-rspec/src/hook.ts b/packages/code-export-ruby-rspec/src/hook.ts index 7c0cbb00d4..7ee865f37d 100644 --- a/packages/code-export-ruby-rspec/src/hook.ts +++ b/packages/code-export-ruby-rspec/src/hook.ts @@ -14,7 +14,7 @@ // KIND, either express or implied. See the License for the specific language governing permissions and limitations // under the License. -import { HookFunctionInputs } from "side-code-export"; +import { HookFunctionInputs } from 'side-code-export' const emitters = { afterAll, @@ -28,7 +28,7 @@ const emitters = { inEachEnd: empty, } -export default emitters; +export default emitters function afterAll() { const params = { diff --git a/packages/code-export-ruby-rspec/src/index.ts b/packages/code-export-ruby-rspec/src/index.ts index f5f4dd6047..30af7666b6 100644 --- a/packages/code-export-ruby-rspec/src/index.ts +++ b/packages/code-export-ruby-rspec/src/index.ts @@ -60,4 +60,4 @@ export const opts: LanguageEmitterOpts = { }, } -export default languageFromOpts(opts, location.emit); \ No newline at end of file +export default languageFromOpts(opts, location.emit) diff --git a/packages/side-code-export/__test__/src/code-export/preprocessor.spec.ts b/packages/side-code-export/__test__/src/code-export/preprocessor.spec.ts index 141b89ab1a..c8406d2a32 100644 --- a/packages/side-code-export/__test__/src/code-export/preprocessor.spec.ts +++ b/packages/side-code-export/__test__/src/code-export/preprocessor.spec.ts @@ -19,6 +19,8 @@ import { defaultPreprocessor, scriptPreprocessor, keysPreprocessor, + preprocessParameter, + resolveVariables, } from '../../../src/code-export/preprocessor' describe('preprocessors', () => { @@ -32,9 +34,60 @@ describe('preprocessors', () => { command: 'echo', target: '${var}', } - return expect( - defaultPreprocessor(command.target, variableLookup) - ).toMatch('vars.get("var").toString()') + const target = defaultPreprocessor(command.target, variableLookup) + return expect(resolveVariables(target, variableLookup)).toBe( + 'vars.get("var").toString()' + ) + }) + }) + describe('resolve variables', () => { + const python = { + quotes: ['"', "'"], + toString: (lookup: string) => `str(${lookup})`, + } + const pythonLookup = (name: string) => `self.vars["${name}"]` + const resolve = (code: string) => + resolveVariables( + code.replace(/\$\{(\w+)\}/g, (match) => defaultPreprocessor(match)), + pythonLookup, + python + ) + it('should join a variable with the text around it', () => { + expect(resolve('print("a ${x} b")')).toBe( + 'print("a " + str(self.vars["x"]) + " b")' + ) + }) + it('should convert a variable that is the whole string', () => { + expect(resolve('find("${x}")')).toBe('find(str(self.vars["x"]))') + }) + it('should join several variables', () => { + expect(resolve("find('#${x}${y}')")).toBe( + 'find(\'#\' + str(self.vars["x"]) + str(self.vars["y"]))' + ) + }) + it('should use the lookup outside of a string', () => { + expect(resolve('range(0, ${x})')).toBe('range(0, self.vars["x"])') + }) + it('should keep a mark with no end as it is', () => { + expect(resolveVariables('print("a \uE000x")', pythonLookup, python)).toBe( + 'print("a \uE000x")' + ) + }) + it('should remove marks typed in a parameter', () => { + const target = preprocessParameter( + "\uE000x') + `id`\uE001", + undefined, + pythonLookup, + { ignoreEscaping: false } + ) + const code = resolveVariables(`run("${target}")`, pythonLookup, python) + expect(code).not.toMatch(/[\uE000\uE001]/) + expect(code).not.toContain('self.vars') + }) + it('should skip escaped quotes', () => { + expect(resolve('find("//*[@id=\\"${x}\\"]")')).toBe( + 'find("//*[@id=\\"" + str(self.vars["x"]) + "\\"]")' + ) }) }) describe('script', () => { @@ -119,8 +172,9 @@ describe('preprocessors', () => { target: 'id=t', value: 'test${var}foo${KEY_ENTER}', } + const keys = await keysPreprocessor(command.value, variableLookup) return expect( - await keysPreprocessor(command.value, variableLookup) + keys.map((key) => resolveVariables(key, variableLookup)) ).toEqual(['test', 'vars.get("var").toString()', 'foo', `Key['ENTER']`]) }) }) diff --git a/packages/side-code-export/src/code-export/emit.ts b/packages/side-code-export/src/code-export/emit.ts index 97983c714c..24406b5e83 100644 --- a/packages/side-code-export/src/code-export/emit.ts +++ b/packages/side-code-export/src/code-export/emit.ts @@ -19,6 +19,8 @@ import stringEscape from '../string-escape' import { Preprocessor, preprocessParameter, + resolveVariables, + VariableInStringOptions, VariableLookup, } from './preprocessor' import doRender, { PartialRenderParameters } from './render' @@ -95,6 +97,34 @@ export interface EmitCommandContext { context: EmitterContext ) => Promise variableLookup: VariableLookup + // How the language puts a variable in a string literal + variableInString?: VariableInStringOptions +} + +// Replaces the variable marks left by the preprocessors in emitted code +function resolveEmittedVariables( + result: ExportFlexCommandShape, + variableLookup: VariableLookup, + variableInString?: VariableInStringOptions +): ExportFlexCommandShape { + const resolve = (code: string) => + resolveVariables(code, variableLookup, variableInString) + if (!result) return result + if (typeof result === 'string') return resolve(result) + if ('statement' in result) { + return { ...result, statement: resolve(result.statement) } + } + return { + ...result, + commands: result.commands.map( + (command) => + resolveEmittedVariables( + command, + variableLookup, + variableInString + ) as ExportCommandShape + ), + } } export interface ProcessedCommandEmitter { @@ -110,7 +140,11 @@ export interface ProcessedCommandEmitter { export function baseEmitFactory( command: CommandShape, emitter: ProcessedCommandEmitter, - { variableLookup, emitNewWindowHandling }: EmitCommandContext + { + variableLookup, + emitNewWindowHandling, + variableInString, + }: EmitCommandContext ) { return async function emit( target: any | undefined, @@ -126,7 +160,11 @@ export function baseEmitFactory( if (emitter.valuePreprocessor) { _value = await emitter.valuePreprocessor(value, variableLookup) } - const result = await emitter(_target, _value, context) + const result = resolveEmittedVariables( + await emitter(_target, _value, context), + variableLookup, + variableInString + ) return emitNewWindowHandling(command, result, context) } } @@ -134,12 +172,17 @@ export function baseEmitFactory( export async function emitCommand( command: CommandShape, emitter: ProcessedCommandEmitter, - { context, emitNewWindowHandling, variableLookup }: EmitCommandContext + { + context, + emitNewWindowHandling, + variableLookup, + variableInString, + }: EmitCommandContext ) { validateCommand(command) if (emitter) { const ignoreEscaping = command.command === 'storeJson' - let result = await emitter( + const emitted = await emitter( preprocessParameter( command.target as string, emitter.targetPreprocessor, @@ -154,6 +197,11 @@ export async function emitCommand( ), context ) + const result = resolveEmittedVariables( + emitted, + variableLookup, + variableInString + ) if (command.opensWindow) { return await emitNewWindowHandling(command, result, context) } diff --git a/packages/side-code-export/src/code-export/preprocessor.ts b/packages/side-code-export/src/code-export/preprocessor.ts index bfbc113f92..48522ca951 100644 --- a/packages/side-code-export/src/code-export/preprocessor.ts +++ b/packages/side-code-export/src/code-export/preprocessor.ts @@ -47,13 +47,27 @@ function escapeString( else return stringEscape(str) } +// Private use characters that mark a variable in a preprocessed parameter. +// resolveVariables replaces the marks once the code around them is emitted. +const VARIABLE_START = '\uE000' +const VARIABLE_END = '\uE001' + +// Only marks made by defaultPreprocessor may reach resolveVariables +const removeVariableMarks = (param: string) => + typeof param === 'string' + ? param.replace(new RegExp(`[${VARIABLE_START}${VARIABLE_END}]`, 'g'), '') + : param + export function preprocessParameter( param: string, preprocessor: Preprocessor | undefined, variableLookup: VariableLookup, { ignoreEscaping }: PreprocessorOptions ) { - const escapedParam = escapeString(param, { preprocessor, ignoreEscaping }) + const escapedParam = escapeString(removeVariableMarks(param), { + preprocessor, + ignoreEscaping, + }) return preprocessor ? preprocessor(escapedParam, variableLookup) : defaultPreprocessor(escapedParam, variableLookup) @@ -61,15 +75,89 @@ export function preprocessParameter( export function defaultPreprocessor( param: string, - variableLookup: VariableLookup + _variableLookup?: VariableLookup ) { if (!param) return '' - const _var = param.match(/\$\{(\w+)\}/) - if (_var) { - return param.replace(_var[0], variableLookup(_var[1])) - } else { - return param + return param.replace( + /\$\{(\w+)\}/g, + (_match, name) => `${VARIABLE_START}${name}${VARIABLE_END}` + ) +} + +export interface VariableInStringOptions { + // Quote characters that start a string literal in the language + quotes: string[] + // Converts a variable lookup to a string, to join it with a string literal + toString: (lookup: string) => string +} + +export const defaultVariableInStringOptions: VariableInStringOptions = { + quotes: ['"'], + toString: (lookup) => lookup, +} + +/** + * Replaces the variable marks in an emitted line of code. A mark outside a + * string literal becomes the variable lookup. A mark inside a string literal + * splits it: "Hi ${name}!" becomes "Hi " + name + "!". + */ +export function resolveVariables( + code: string, + variableLookup: VariableLookup, + { quotes, toString }: VariableInStringOptions = defaultVariableInStringOptions +) { + if (!code.includes(VARIABLE_START)) return code + let result = '' + let quote: string | undefined + // Where the text of the current string literal starts in the result + let textStart = 0 + for (let i = 0; i < code.length; i++) { + const character = code[i] + if (character === VARIABLE_START) { + const end = code.indexOf(VARIABLE_END, i) + if (end === -1) { + result += code.slice(i) + break + } + const lookup = variableLookup(code.slice(i + 1, end)) + i = end + if (!quote) { + result += lookup + continue + } + const startsString = result.length === textStart + const endsString = code[i + 1] === quote + // Close the string before the variable, unless it's still empty + if (startsString) result = result.slice(0, -1) + else result += `${quote} + ` + result += toString(lookup) + // Open the string again after the variable, unless it ends there + if (endsString) { + quote = undefined + i++ + } else { + result += ` + ${quote}` + textStart = result.length + } + } else if ( + quote && + character === '\\' && + code[i + 1] !== undefined && + code[i + 1] !== VARIABLE_START + ) { + result += character + code[i + 1] + i++ + } else { + if (character === quote) { + quote = undefined + } else if (!quote && quotes.includes(character)) { + quote = character + textStart = result.length + 1 + } + result += character + } } + return result } export function scriptPreprocessor(script: string): ScriptShape {