Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 43 additions & 43 deletions src/Executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,44 +89,30 @@ interface CompiledSnippet {
}

/**
* Transform {{keyspec}} syntax into key lookups.
* Transform {{keyspec}} syntax into accessor expressions.
*
* {{foo/bar}} becomes calls to the __get helper function.
* {{foo/bar}} = value becomes calls to the __set helper function.
* {{foo/bar}} += value becomes __set(R, "foo/bar", __get(R, "foo/bar") + value)
* Uses a language-native lvalue approach: the expansion produces an expression
* that can appear on both the left and right side of assignments. This means
* the language itself handles reads, writes, compound assignments (+=, *=, etc.),
* and even increment/decrement — no regex-based assignment detection needed.
*
* The recordVar parameter controls the variable name used in generated code
* (e.g. "r" for JS/Python, "$r" for Perl).
* For JS/Python ("accessor" style): {{foo/bar}} becomes __F["foo/bar"]
* where __F is a Proxy (JS) or __getitem__/__setitem__ object (Python).
*
* For Perl ("lvalue" style): {{foo/bar}} becomes _f("foo/bar")
* where _f is a Perl lvalue sub that returns a modifiable hash element.
*/
export function transformCode(code: string, recordVar = "r"): string {
// Step 1: Replace compound assignments like {{ks}} += val
// Operators listed longest-first for correct matching
let transformed = code.replace(
/\{\{(.*?)\}\}\s*(\*\*|>>>|>>|<<|\?\?|\/\/|\|\||&&|\+|-|\*|\/|%|&|\||\^)=\s*([^;,\n]+)/g,
(_match, keyspec: string, op: string, value: string) => {
const ks = JSON.stringify(keyspec);
return `__set(${recordVar}, ${ks}, __get(${recordVar}, ${ks}) ${op} ${value.trim()})`;
}
);

// Step 2: Replace simple assignments like {{ks}} = val
// Negative lookahead (?!=) ensures == and === are not matched
transformed = transformed.replace(
/\{\{(.*?)\}\}\s*=(?!=)\s*([^;,\n]+)/g,
(_match, keyspec: string, value: string) => {
return `__set(${recordVar}, ${JSON.stringify(keyspec)}, ${value.trim()})`;
}
);

// Step 3: Replace remaining {{keyspec}} reads
transformed = transformed.replace(
export function transformCode(code: string, style: "accessor" | "lvalue" = "accessor"): string {
return code.replace(
/\{\{(.*?)\}\}/g,
(_match, keyspec: string) => {
return `__get(${recordVar}, ${JSON.stringify(keyspec)})`;
const ks = JSON.stringify(keyspec);
if (style === "lvalue") {
return `_f(${ks})`;
}
return `__F[${ks}]`;
}
);

return transformed;
}

/**
Expand Down Expand Up @@ -169,18 +155,30 @@ function compileSnippet(
// Build argument list: user args + line + filename
const allArgNames = [...argNames, "$line", "$filename"];

// Helper functions available to snippets
const helperCode = `
const __get = (r, keyspec) => {
const data = typeof r === 'object' && r !== null && 'dataRef' in r ? r.dataRef() : r;
return __findKey(data, '@' + keyspec);
};
const __set = (r, keyspec, value) => {
const data = typeof r === 'object' && r !== null && 'dataRef' in r ? r.dataRef() : r;
__setKey(data, '@' + keyspec, value);
return value;
};
`;
// __F is a Proxy that makes {{keyspec}} expansions work as native lvalues.
// {{x}} expands to __F["x"], and the Proxy's get/set traps handle
// KeySpec resolution. This means reads, writes, compound assignments (+=),
// increment (++), etc. all work without any assignment-detection regex.
//
// Only created when the transformed code actually uses __F (i.e. the
// original code contained {{}} templates). Snippets without templates
// (e.g. named snippets with custom args) skip this entirely.
const needsProxy = code.includes("__F[");
const helperCode = needsProxy
? `
const __data = typeof r === 'object' && r !== null && 'dataRef' in r ? r.dataRef() : r;
const __F = new Proxy(Object.create(null), {
get(_, prop) {
if (typeof prop === 'string') return __findKey(__data, '@' + prop);
return undefined;
},
set(_, prop, value) {
if (typeof prop === 'string') __setKey(__data, '@' + prop, value);
return true;
},
});
`
: "";

const fnBody = `
${helperCode}
Expand All @@ -192,13 +190,15 @@ function compileSnippet(
const factory = new Function(
"__findKey",
"__setKey",
"Proxy",
"state",
`return function(${allArgNames.join(", ")}) { ${fnBody} }`
);
return factory(
(data: JsonObject, spec: string) => findKey(data, spec),
(data: JsonObject, spec: string, value: JsonValue) =>
setKey(data, spec, value),
Proxy,
state,
);
} catch (e) {
Expand Down
2 changes: 1 addition & 1 deletion src/snippets/PerlSnippetRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export class PerlSnippetRunner implements SnippetRunner {
#mode: SnippetMode = "eval";

async init(code: string, context: SnippetContext): Promise<void> {
this.#code = transformCode(code, "$r");
this.#code = transformCode(code, "lvalue");
this.#mode = context.mode;
}

Expand Down
2 changes: 1 addition & 1 deletion src/snippets/PythonSnippetRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export class PythonSnippetRunner implements SnippetRunner {
#mode: SnippetMode = "eval";

async init(code: string, context: SnippetContext): Promise<void> {
this.#code = transformCode(code, "r");
this.#code = transformCode(code, "accessor");
this.#mode = context.mode;
}

Expand Down
44 changes: 34 additions & 10 deletions src/snippets/perl/runner.pl
Original file line number Diff line number Diff line change
Expand Up @@ -69,18 +69,41 @@ sub write_message {
}

# ----------------------------------------------------------------
# __get / __set helpers for {{}} template expansion
# Lvalue sub for {{}} template expansion
# ----------------------------------------------------------------
#
# {{x}} expands to _f("x") — an lvalue sub that returns the hash
# element directly. This means reads, writes, compound assignments
# (+=, *=, etc.), and even ++ all work natively without any
# assignment-detection regex. $r is set per-record in the main loop.

my $_current_r;

sub _f : lvalue {
my ($keyspec) = @_;
my @parts = RecsSDK::_split_keyspec($keyspec);

my $node = $_current_r;
for my $i (0 .. $#parts - 1) {
my $part = $parts[$i];
my $next_part = $parts[$i + 1];
my $next_is_array = ($next_part =~ /^#\d+$/);

if ($part =~ /^#(\d+)$/) {
$node->[$1] //= ($next_is_array ? [] : {});
$node = $node->[$1];
} else {
$node->{$part} //= ($next_is_array ? [] : {});
$node = $node->{$part};
}
}

sub __get {
my ($r, $keyspec) = @_;
return RecsSDK::_resolve($r, $keyspec);
}

sub __set {
my ($r, $keyspec, $value) = @_;
RecsSDK::_set_path($r, $keyspec, $value);
return $value;
my $last = $parts[-1];
if ($last =~ /^#(\d+)$/) {
$node->[$1];
} else {
$node->{$last};
}
}

# ----------------------------------------------------------------
Expand Down Expand Up @@ -153,6 +176,7 @@ sub push_record {

$line_num++;
my $r = RecsSDK->new($msg->{data});
$_current_r = $r;

eval {
if ($mode eq 'eval') {
Expand Down
33 changes: 25 additions & 8 deletions src/snippets/python/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,26 @@
)


class _FieldAccessor:
"""Proxy-like accessor for {{keyspec}} template expansion.

``__F["x"]`` reads via KeySpec, ``__F["x"] = v`` writes via KeySpec,
and ``__F["x"] += v`` triggers ``__getitem__`` then ``__setitem__`` —
no assignment-detection regex needed.
"""

__slots__ = ("_rec",)

def __init__(self, rec: Record) -> None:
self._rec = rec

def __getitem__(self, key: str) -> Any:
return self._rec.get("@" + key)

def __setitem__(self, key: str, value: Any) -> None:
self._rec.set("@" + key, value)


def _compile_snippet(code: str, mode: str) -> Any:
"""Compile user code into a code object.

Expand Down Expand Up @@ -116,12 +136,10 @@ def emit(rec_or_dict: Any) -> None:
f"emit() expects a Record or dict, got {type(rec_or_dict).__name__}"
)

def __get(rec: Record, ks: str) -> Any:
return rec.get("@" + ks)

def __set(rec: Record, ks: str, value: Any) -> Any:
rec.set("@" + ks, value)
return value
# __F is a field accessor that makes {{keyspec}} expansions work as
# native lvalues. {{x}} expands to __F["x"], and Python's
# __getitem__/__setitem__ handle reads, writes, and compound assignments.
__f = _FieldAccessor(r)

# Build the snippet namespace
namespace: dict[str, Any] = {
Expand All @@ -131,8 +149,7 @@ def __set(rec: Record, ks: str, value: Any) -> Any:
"filename": "NONE",
"emit": emit,
"Record": Record,
"__get": __get,
"__set": __set,
"__F": __f,
# Expose builtins for convenience
"json": __import__("json"),
"re": __import__("re"),
Expand Down
Loading