From 00cb4f252dfcc2e04aaa2ebef9ee5b3a7b2d8309 Mon Sep 17 00:00:00 2001 From: John Chilton Date: Wed, 10 Jan 2024 12:20:57 -0500 Subject: [PATCH] TS library function for replacing gx markdown labels. --- client/src/components/Markdown/parse.test.js | 104 ++++++++++++++++++- client/src/components/Markdown/parse.ts | 70 +++++++++++-- 2 files changed, 166 insertions(+), 8 deletions(-) diff --git a/client/src/components/Markdown/parse.test.js b/client/src/components/Markdown/parse.test.js index 782170804fe9..d8b11134410e 100644 --- a/client/src/components/Markdown/parse.test.js +++ b/client/src/components/Markdown/parse.test.js @@ -1,4 +1,4 @@ -import { getArgs } from "./parse"; +import { getArgs, replaceLabel, splitMarkdown } from "./parse"; describe("parse.ts", () => { describe("getArgs", () => { @@ -7,4 +7,106 @@ describe("parse.ts", () => { expect(args.name).toBe("job_metrics"); }); }); + + describe("splitMarkdown", () => { + it("strip leading whitespace by default", () => { + const { sections } = splitMarkdown("\n```galaxy\njob_metrics(job_id=THISFAKEID)\n```"); + expect(sections.length).toBe(1); + }); + + it("should not strip leading whitespace if disabled", () => { + const { sections } = splitMarkdown("\n```galaxy\njob_metrics(job_id=THISFAKEID)\n```", true); + expect(sections.length).toBe(2); + expect(sections[0].content).toBe("\n"); + }); + }); + + describe("replaceLabel", () => { + it("should leave unaffected markdown alone", () => { + const input = "some random\n`markdown content`\n"; + const result = replaceLabel(input, "output", "from", "to"); + expect(result).toBe(result); + }); + + it("should leave unaffected galaxy directives alone", () => { + const input = "some random\n`markdown content`\n```galaxy\ncurrent_time()\n```\n"; + const result = replaceLabel(input, "output", "from", "to"); + expect(result).toBe(result); + }); + + it("should leave galaxy directives of same type with other labels alone", () => { + const input = "some random\n`markdown content`\n```galaxy\nhistory_dataset_embedded(output=moo)\n```\n"; + const result = replaceLabel(input, "output", "from", "to"); + expect(result).toBe(result); + }); + + it("should leave galaxy directives of other types with same labels alone", () => { + const input = "some random\n`markdown content`\n```galaxy\nhistory_dataset_embedded(input=from)\n```\n"; + const result = replaceLabel(input, "output", "from", "to"); + expect(result).toBe(result); + }); + + it("should swap simple directives of specified type", () => { + const input = "some random\n`markdown content`\n```galaxy\nhistory_dataset_embedded(output=from)\n```\n"; + const output = "some random\n`markdown content`\n```galaxy\nhistory_dataset_embedded(output=to)\n```\n"; + const result = replaceLabel(input, "output", "from", "to"); + expect(result).toBe(output); + }); + + it("should swap single quoted directives of specified type", () => { + const input = "some random\n`markdown content`\n```galaxy\nhistory_dataset_embedded(output='from')\n```\n"; + const output = "some random\n`markdown content`\n```galaxy\nhistory_dataset_embedded(output=to)\n```\n"; + const result = replaceLabel(input, "output", "from", "to"); + expect(result).toBe(output); + }); + + it("should swap single quoted directives of specified type with extra args", () => { + const input = + "some random\n`markdown content`\n```galaxy\nhistory_dataset_embedded(footer='cow', output='from', title=dog)\n```\n"; + const output = + "some random\n`markdown content`\n```galaxy\nhistory_dataset_embedded(footer='cow', output=to, title=dog)\n```\n"; + const result = replaceLabel(input, "output", "from", "to"); + expect(result).toBe(output); + }); + + it("should swap double quoted directives of specified type", () => { + const input = 'some random\n`markdown content`\n```galaxy\nhistory_dataset_embedded(output="from")\n```\n'; + const output = "some random\n`markdown content`\n```galaxy\nhistory_dataset_embedded(output=to)\n```\n"; + const result = replaceLabel(input, "output", "from", "to"); + expect(result).toBe(output); + }); + + it("should swap double quoted directives of specified type with extra args", () => { + const input = + "some random\n`markdown content`\n```galaxy\nhistory_dataset_embedded(footer='cow', output=\"from\", title=dog)\n```\n"; + const output = + "some random\n`markdown content`\n```galaxy\nhistory_dataset_embedded(footer='cow', output=to, title=dog)\n```\n"; + const result = replaceLabel(input, "output", "from", "to"); + expect(result).toBe(output); + }); + + it("should leave non-arguments alone", () => { + const input = + "some random\n`markdown content`\n```galaxy\nhistory_dataset_embedded(title='cow from farm', output=from)\n```\n"; + const output = + "some random\n`markdown content`\n```galaxy\nhistory_dataset_embedded(title='cow from farm', output=to)\n```\n"; + const result = replaceLabel(input, "output", "from", "to"); + expect(result).toBe(output); + }); + + // not a valid workflow label per se but make sure we're escaping the regex to be safe + it("should not be messed up by labels containing regexp content", () => { + const input = "```galaxy\nhistory_dataset_embedded(output='from(')\n```\n"; + const output = "```galaxy\nhistory_dataset_embedded(output=to$1)\n```\n"; + const result = replaceLabel(input, "output", "from(", "to$1"); + expect(result).toBe(output); + }); + + it("should not swallow leading newlines", () => { + const input = "\n```galaxy\nhistory_dataset_embedded(output='from')\n```\n"; + const output = "\n```galaxy\nhistory_dataset_embedded(output=to)\n```\n"; + const result = replaceLabel(input, "output", "from", "to"); + expect(result).toBe(output); + }); + }); }); diff --git a/client/src/components/Markdown/parse.ts b/client/src/components/Markdown/parse.ts index 0b00cdf11052..7a2ee3d1856a 100644 --- a/client/src/components/Markdown/parse.ts +++ b/client/src/components/Markdown/parse.ts @@ -3,8 +3,14 @@ const FUNCTION_ARGUMENT_REGEX = `\\s*[\\w\\|]+\\s*=` + FUNCTION_ARGUMENT_VALUE_R const FUNCTION_CALL_LINE = `\\s*(\\w+)\\s*\\(\\s*(?:(${FUNCTION_ARGUMENT_REGEX})(,${FUNCTION_ARGUMENT_REGEX})*)?\\s*\\)\\s*`; const FUNCTION_CALL_LINE_TEMPLATE = new RegExp(FUNCTION_CALL_LINE, "m"); -export function splitMarkdown(markdown: string) { - const sections = []; +type DefaultSection = { name: "default"; content: string }; +type GalaxyDirectiveSection = { name: string; content: string; args: { [key: string]: string } }; +type Section = DefaultSection | GalaxyDirectiveSection; + +type WorkflowLabelKind = "input" | "output" | "step"; + +export function splitMarkdown(markdown: string, preserveWhitespace: boolean = false) { + const sections: Section[] = []; const markdownErrors = []; let digest = markdown; while (digest.length > 0) { @@ -13,11 +19,12 @@ export function splitMarkdown(markdown: string) { const galaxyEnd = digest.substr(galaxyStart + 1).indexOf("```"); if (galaxyEnd != -1) { if (galaxyStart > 0) { - const defaultContent = digest.substr(0, galaxyStart).trim(); - if (defaultContent) { + const rawContent = digest.substr(0, galaxyStart); + const defaultContent = rawContent.trim(); + if (preserveWhitespace || defaultContent) { sections.push({ name: "default", - content: defaultContent, + content: preserveWhitespace ? rawContent : defaultContent, }); } } @@ -48,14 +55,54 @@ export function splitMarkdown(markdown: string) { return { sections, markdownErrors }; } -export function getArgs(content: string) { +export function replaceLabel( + markdown: string, + labelType: WorkflowLabelKind, + fromLabel: string, + toLabel: string +): string { + const { sections } = splitMarkdown(markdown, true); + + function rewriteSection(section: Section) { + if ("args" in section) { + const directiveSection = section as GalaxyDirectiveSection; + const args = directiveSection.args; + if (!(labelType in args)) { + return section; + } + const labelValue = args[labelType]; + if (labelValue != fromLabel) { + return section; + } + // we've got a section with a matching label and type... + const newArgs = { ...args }; + newArgs[labelType] = toLabel; + const argRexExp = namedArgumentRegex(labelType); + const escapedToLabel = escapeRegExpReplacement(toLabel); + const content = directiveSection.content.replace(argRexExp, `$1${escapedToLabel}`); + return { + name: directiveSection.name, + args: newArgs, + content: content, + }; + } else { + return section; + } + } + + const rewrittenSections = sections.map(rewriteSection); + const rewrittenMarkdown = rewrittenSections.map((section) => section.content).join(""); + return rewrittenMarkdown; +} + +export function getArgs(content: string): GalaxyDirectiveSection { const galaxy_function = FUNCTION_CALL_LINE_TEMPLATE.exec(content); if (galaxy_function == null) { throw Error("Failed to parse galaxy directive"); } type ArgsType = { [key: string]: string }; const args: ArgsType = {}; - const function_name = galaxy_function[1]; + const function_name = galaxy_function[1] as string; // we need [... ] to return empty string, if regex doesn't match const function_arguments = [...content.matchAll(new RegExp(FUNCTION_ARGUMENT_REGEX, "g"))]; for (let i = 0; i < function_arguments.length; i++) { @@ -77,3 +124,12 @@ export function getArgs(content: string) { content: content, }; } + +function namedArgumentRegex(argument: string): RegExp { + return new RegExp(`(\\s*${argument}\\s*=)` + FUNCTION_ARGUMENT_VALUE_REGEX); +} + +// https://stackoverflow.com/questions/3446170/escape-string-for-use-in-javascript-regex +function escapeRegExpReplacement(value: string): string { + return value.replace(/\$/g, "$$$$"); +}