Skip to content

Commit ff9d030

Browse files
authored
Merge pull request #95 from benbernard/feature/template-compound-and-polyglot
feat: {{}} compound assignments and polyglot support
2 parents 5aa2eee + e5f3b34 commit ff9d030

7 files changed

Lines changed: 345 additions & 8 deletions

File tree

src/Executor.ts

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -93,22 +93,36 @@ interface CompiledSnippet {
9393
*
9494
* {{foo/bar}} becomes calls to the __get helper function.
9595
* {{foo/bar}} = value becomes calls to the __set helper function.
96+
* {{foo/bar}} += value becomes __set(R, "foo/bar", __get(R, "foo/bar") + value)
97+
*
98+
* The recordVar parameter controls the variable name used in generated code
99+
* (e.g. "r" for JS/Python, "$r" for Perl).
96100
*/
97-
export function transformCode(code: string): string {
98-
// Replace {{keyspec}} = value with __set(r, "keyspec", value)
99-
// Negative lookahead (?!=) ensures == and === are not matched as assignment
101+
export function transformCode(code: string, recordVar = "r"): string {
102+
// Step 1: Replace compound assignments like {{ks}} += val
103+
// Operators listed longest-first for correct matching
100104
let transformed = code.replace(
105+
/\{\{(.*?)\}\}\s*(\*\*|>>>|>>|<<|\?\?|\/\/|\|\||&&|\+|-|\*|\/|%|&|\||\^)=\s*([^;,\n]+)/g,
106+
(_match, keyspec: string, op: string, value: string) => {
107+
const ks = JSON.stringify(keyspec);
108+
return `__set(${recordVar}, ${ks}, __get(${recordVar}, ${ks}) ${op} ${value.trim()})`;
109+
}
110+
);
111+
112+
// Step 2: Replace simple assignments like {{ks}} = val
113+
// Negative lookahead (?!=) ensures == and === are not matched
114+
transformed = transformed.replace(
101115
/\{\{(.*?)\}\}\s*=(?!=)\s*([^;,\n]+)/g,
102116
(_match, keyspec: string, value: string) => {
103-
return `__set(r, ${JSON.stringify(keyspec)}, ${value.trim()})`;
117+
return `__set(${recordVar}, ${JSON.stringify(keyspec)}, ${value.trim()})`;
104118
}
105119
);
106120

107-
// Replace remaining {{keyspec}} with __get(r, "keyspec")
121+
// Step 3: Replace remaining {{keyspec}} reads
108122
transformed = transformed.replace(
109123
/\{\{(.*?)\}\}/g,
110124
(_match, keyspec: string) => {
111-
return `__get(r, ${JSON.stringify(keyspec)})`;
125+
return `__get(${recordVar}, ${JSON.stringify(keyspec)})`;
112126
}
113127
);
114128

src/snippets/PerlSnippetRunner.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import type {
1717
SnippetMode,
1818
} from "./SnippetRunner.ts";
1919
import { groupResponses } from "./SnippetRunner.ts";
20+
import { transformCode } from "../Executor.ts";
2021

2122
const SNIPPETS_DIR = dirname(fileURLToPath(import.meta.url));
2223
const RUNNER_DIR = join(SNIPPETS_DIR, "perl");
@@ -28,7 +29,7 @@ export class PerlSnippetRunner implements SnippetRunner {
2829
#mode: SnippetMode = "eval";
2930

3031
async init(code: string, context: SnippetContext): Promise<void> {
31-
this.#code = code;
32+
this.#code = transformCode(code, "$r");
3233
this.#mode = context.mode;
3334
}
3435

src/snippets/PythonSnippetRunner.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import type {
1818
SnippetMode,
1919
} from "./SnippetRunner.ts";
2020
import { groupResponses } from "./SnippetRunner.ts";
21+
import { transformCode } from "../Executor.ts";
2122

2223
const SNIPPETS_DIR = dirname(fileURLToPath(import.meta.url));
2324
const RUNNER_DIR = join(SNIPPETS_DIR, "python");
@@ -29,7 +30,7 @@ export class PythonSnippetRunner implements SnippetRunner {
2930
#mode: SnippetMode = "eval";
3031

3132
async init(code: string, context: SnippetContext): Promise<void> {
32-
this.#code = code;
33+
this.#code = transformCode(code, "r");
3334
this.#mode = context.mode;
3435
}
3536

src/snippets/perl/runner.pl

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,21 @@ sub write_message {
6464
exit 1;
6565
}
6666

67+
# ----------------------------------------------------------------
68+
# __get / __set helpers for {{}} template expansion
69+
# ----------------------------------------------------------------
70+
71+
sub __get {
72+
my ($r, $keyspec) = @_;
73+
return RecsSDK::_resolve($r, $keyspec);
74+
}
75+
76+
sub __set {
77+
my ($r, $keyspec, $value) = @_;
78+
RecsSDK::_set_path($r, $keyspec, $value);
79+
return $value;
80+
}
81+
6782
# ----------------------------------------------------------------
6883
# Compile snippet
6984
# ----------------------------------------------------------------

src/snippets/python/runner.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,13 @@ def emit(rec_or_dict: Any) -> None:
116116
f"emit() expects a Record or dict, got {type(rec_or_dict).__name__}"
117117
)
118118

119+
def __get(rec: Record, ks: str) -> Any:
120+
return rec.get("@" + ks)
121+
122+
def __set(rec: Record, ks: str, value: Any) -> Any:
123+
rec.set("@" + ks, value)
124+
return value
125+
119126
# Build the snippet namespace
120127
namespace: dict[str, Any] = {
121128
"r": r,
@@ -124,6 +131,8 @@ def emit(rec_or_dict: Any) -> None:
124131
"filename": "NONE",
125132
"emit": emit,
126133
"Record": Record,
134+
"__get": __get,
135+
"__set": __set,
127136
# Expose builtins for convenience
128137
"json": __import__("json"),
129138
"re": __import__("re"),

tests/Executor.test.ts

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,118 @@ describe("Executor", () => {
2828
expect(result).toContain('"a"');
2929
expect(result).toContain('"b"');
3030
});
31+
32+
test("transforms {{key}} += value to compound assignment", () => {
33+
const result = transformCode("{{x}} += 2");
34+
expect(result).toBe('__set(r, "x", __get(r, "x") + 2)');
35+
});
36+
37+
test("transforms {{key}} -= value to compound assignment", () => {
38+
const result = transformCode("{{x}} -= 1");
39+
expect(result).toBe('__set(r, "x", __get(r, "x") - 1)');
40+
});
41+
42+
test("transforms {{key}} *= value to compound assignment", () => {
43+
const result = transformCode("{{x}} *= 3");
44+
expect(result).toBe('__set(r, "x", __get(r, "x") * 3)');
45+
});
46+
47+
test("transforms {{key}} /= value to compound assignment", () => {
48+
const result = transformCode("{{x}} /= 2");
49+
expect(result).toBe('__set(r, "x", __get(r, "x") / 2)');
50+
});
51+
52+
test("transforms {{key}} **= value to compound assignment", () => {
53+
const result = transformCode("{{x}} **= 2");
54+
expect(result).toBe('__set(r, "x", __get(r, "x") ** 2)');
55+
});
56+
57+
test("transforms {{key}} ||= value to compound assignment", () => {
58+
const result = transformCode("{{x}} ||= 5");
59+
expect(result).toBe('__set(r, "x", __get(r, "x") || 5)');
60+
});
61+
62+
test("transforms {{key}} &&= value to compound assignment", () => {
63+
const result = transformCode("{{x}} &&= 5");
64+
expect(result).toBe('__set(r, "x", __get(r, "x") && 5)');
65+
});
66+
67+
test("transforms {{key}} ??= value to compound assignment", () => {
68+
const result = transformCode("{{x}} ??= 5");
69+
expect(result).toBe('__set(r, "x", __get(r, "x") ?? 5)');
70+
});
71+
72+
test("transforms {{key}} >>= value to compound assignment", () => {
73+
const result = transformCode("{{x}} >>= 1");
74+
expect(result).toBe('__set(r, "x", __get(r, "x") >> 1)');
75+
});
76+
77+
test("transforms {{key}} >>>= value to compound assignment", () => {
78+
const result = transformCode("{{x}} >>>= 1");
79+
expect(result).toBe('__set(r, "x", __get(r, "x") >>> 1)');
80+
});
81+
82+
test("transforms {{key}} <<= value to compound assignment", () => {
83+
const result = transformCode("{{x}} <<= 1");
84+
expect(result).toBe('__set(r, "x", __get(r, "x") << 1)');
85+
});
86+
87+
test("transforms {{key}} |= value to compound assignment", () => {
88+
const result = transformCode("{{x}} |= 3");
89+
expect(result).toBe('__set(r, "x", __get(r, "x") | 3)');
90+
});
91+
92+
test("transforms {{key}} &= value to compound assignment", () => {
93+
const result = transformCode("{{x}} &= 3");
94+
expect(result).toBe('__set(r, "x", __get(r, "x") & 3)');
95+
});
96+
97+
test("transforms {{key}} ^= value to compound assignment", () => {
98+
const result = transformCode("{{x}} ^= 3");
99+
expect(result).toBe('__set(r, "x", __get(r, "x") ^ 3)');
100+
});
101+
102+
test("transforms {{key}} //= value to compound assignment", () => {
103+
const result = transformCode("{{x}} //= 10");
104+
expect(result).toBe('__set(r, "x", __get(r, "x") // 10)');
105+
});
106+
107+
test("transforms {{key}} %= value to compound assignment", () => {
108+
const result = transformCode("{{x}} %= 3");
109+
expect(result).toBe('__set(r, "x", __get(r, "x") % 3)');
110+
});
111+
112+
test("compound assignment with nested keyspec", () => {
113+
const result = transformCode("{{a/b}} += 1");
114+
expect(result).toBe('__set(r, "a/b", __get(r, "a/b") + 1)');
115+
});
116+
117+
test("uses custom recordVar", () => {
118+
const result = transformCode("{{x}} += 1", "$r");
119+
expect(result).toBe('__set($r, "x", __get($r, "x") + 1)');
120+
});
121+
122+
test("uses custom recordVar for simple assign", () => {
123+
const result = transformCode("{{x}} = 5", "$r");
124+
expect(result).toBe('__set($r, "x", 5)');
125+
});
126+
127+
test("uses custom recordVar for read", () => {
128+
const result = transformCode("{{x}}", "$r");
129+
expect(result).toBe('__get($r, "x")');
130+
});
131+
132+
test("does not match == as assignment", () => {
133+
const result = transformCode("{{x}} == 5");
134+
expect(result).not.toContain("__set");
135+
expect(result).toContain("__get");
136+
});
137+
138+
test("does not match === as assignment", () => {
139+
const result = transformCode("{{x}} === 5");
140+
expect(result).not.toContain("__set");
141+
expect(result).toContain("__get");
142+
});
31143
});
32144

33145
describe("executeCode", () => {
@@ -62,6 +174,34 @@ describe("Executor", () => {
62174
expect(data["new_field"]).toBe("created");
63175
});
64176

177+
test("evaluates {{}} compound assignment +=", () => {
178+
const executor = new Executor("{{x}} += 10");
179+
const record = new Record({ x: 5 });
180+
executor.executeCode(record);
181+
expect(record.dataRef()["x"]).toBe(15);
182+
});
183+
184+
test("evaluates {{}} compound assignment -=", () => {
185+
const executor = new Executor("{{x}} -= 3");
186+
const record = new Record({ x: 10 });
187+
executor.executeCode(record);
188+
expect(record.dataRef()["x"]).toBe(7);
189+
});
190+
191+
test("evaluates {{}} compound assignment *=", () => {
192+
const executor = new Executor("{{x}} *= 4");
193+
const record = new Record({ x: 3 });
194+
executor.executeCode(record);
195+
expect(record.dataRef()["x"]).toBe(12);
196+
});
197+
198+
test("evaluates {{}} compound assignment **=", () => {
199+
const executor = new Executor("{{x}} **= 3");
200+
const record = new Record({ x: 2 });
201+
executor.executeCode(record);
202+
expect(record.dataRef()["x"]).toBe(8);
203+
});
204+
65205
test("provides $line counter", () => {
66206
const executor = new Executor("return $line");
67207
const r = new Record({});

0 commit comments

Comments
 (0)