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
76 changes: 76 additions & 0 deletions luaparser/ast.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -98,6 +98,82 @@ def to_pretty_json(root: Node) -> str:
return json.dumps(root, cls=JSONEncoder, indent=4)



class ASTTransformer:
"""An iterative depth-first AST visitor that supports in-place node replacement.

Subclass and override visit_<NodeType>(node) methods. Each method receives
the current node and may return:

- A new :class:`~luaparser.astnodes.Node` instance to replace the current node
- ``None`` or the same node to leave it unchanged

The transformer propagates replacements up to the parent (or returns a new
root from :meth:`visit`). After a replacement, children of the *new* node
are visited -- not the original.

Example::

class NumberDoubler(ASTTransformer):
def visit_Number(self, node):
return Number(node.value * 2)

tree = ast.parse("x = 5")
new_tree = NumberDoubler().visit(tree)
"""

def visit(self, root):
"""Transform *root* and return the (possibly new) root node.

Returns None if *root* is None.
"""
if root is None:
return None

# Each stack entry is (node, parent_info) where parent_info is either
# None (for root) or (key, container) -- container is a Node or list.
node_stack = [(root, None)]

while node_stack:
node, parent_info = node_stack.pop()

if isinstance(node, Node):
# --- call visitor ---
name = "visit_" + node.__class__.__name__
visitor_method = getattr(self, name, None)
if visitor_method is not None:
replacement = visitor_method(node)
if replacement is not None and replacement is not node:
# Replace node in parent (or update root)
if parent_info is not None:
parent_key, parent_container = parent_info
if isinstance(parent_container, list):
parent_container[parent_key] = replacement
else:
setattr(parent_container, parent_key, replacement)
else:
root = replacement
node = replacement # visit replacement's children

# --- push children (reverse order for correct DFS) ---
children = [
attr for attr in node.__dict__.keys()
if not attr.startswith("_")
]
for child_key in reversed(children):
child = node.__dict__[child_key]
if isinstance(child, list):
for i in reversed(range(len(child))):
node_stack.append((child[i], (i, child)))
elif isinstance(child, Node):
node_stack.append((child, (child_key, node)))

elif isinstance(node, list):
for n in reversed(node):
node_stack.append((n, parent_info))

return root

class ASTVisitor:
def visit(self, root):
# base case:
Expand Down
248 changes: 248 additions & 0 deletions luaparser/tests/test_ast.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -158,3 +158,251 @@ def test_to_pretty_json(self):
}"""
)
self.assertEqual(ast.to_pretty_json(ast.parse(src)), exp)


class ASTTransformerTestCase(tests.TestCase):
"""Tests for ASTTransformer."""

def test_noop_returns_same_tree(self):
"""Transformer with no overrides returns identical structure."""
src = "local x = 1"
tree = ast.parse(src)

class NoopTransformer(ast.ASTTransformer):
pass

result = NoopTransformer().visit(tree)
self.assertEqual(ast.to_pretty_str(result), ast.to_pretty_str(tree))
# Root should be the same object when no transforms applied
self.assertIs(result, tree)

def test_replace_number(self):
"""Replace a Number node with a different value."""
src = "local x = 42"
tree = ast.parse(src)

class Doubler(ast.ASTTransformer):
def visit_Number(self, node):
return Number(node.n * 2)

result = Doubler().visit(tree)
self.assertIn("84", ast.to_pretty_str(result))
self.assertNotIn("42", ast.to_pretty_str(result))

def test_replace_string(self):
"""Replace a String literal."""
src = 'local msg = "hello"'
tree = ast.parse(src)

class StringUpper(ast.ASTTransformer):
def visit_String(self, node):
upper_raw = node.raw.upper()
return String(upper_raw.encode(), upper_raw, node.delimiter)

result = StringUpper().visit(tree)
output = ast.to_pretty_str(result)
self.assertIn("HELLO", output)
self.assertNotIn("hello", output)

def test_keep_node_by_returning_none(self):
"""Returning None keeps the original node."""
src = "local x = 42"
tree = ast.parse(src)

class KeepNumbers(ast.ASTTransformer):
def visit_Number(self, node):
if node.n == 42:
return None # keep
return Number(0)

result = KeepNumbers().visit(tree)
self.assertEqual(ast.to_pretty_str(result), ast.to_pretty_str(tree))

def test_keep_node_by_returning_same(self):
"""Returning the same node object keeps it."""
src = "local x = 42"
tree = ast.parse(src)

class IdentityTransformer(ast.ASTTransformer):
def visit_Number(self, node):
return node # same object

result = IdentityTransformer().visit(tree)
self.assertEqual(ast.to_pretty_str(result), ast.to_pretty_str(tree))
self.assertIs(result, tree)

def test_replace_statement(self):
"""Replace an entire statement node."""
src = "local x = 1"
tree = ast.parse(src)

class AssignToNil(ast.ASTTransformer):
def visit_LocalAssign(self, node):
# Replace local assignment with a nil assignment
return Assign(node.targets, [Nil()])

result = AssignToNil().visit(tree)
output = ast.to_lua_source(result)
self.assertIn("nil", output)
self.assertNotIn("local", output)

def test_replace_multiple_in_list(self):
"""Replace multiple nodes in a list (block body)."""
src = textwrap.dedent("""\
local a = 1
local b = 2
local c = 3
""")
tree = ast.parse(src)

class DropMiddleStatement(ast.ASTTransformer):
def visit_LocalAssign(self, node):
target_name = node.targets[0].id
if target_name == "b":
return None # remove it -- handled via parent removal
return node

result = DropMiddleStatement().visit(tree)
output = ast.to_lua_source(result)
self.assertIn("local a", output)
self.assertIn("local c", output)
# 'local b' should still be there since returning None keeps the node
self.assertIn("local b", output)

def test_replace_in_call_args(self):
"""Replace an argument inside a function call."""
src = "print(42)"
tree = ast.parse(src)

class ArgReplacer(ast.ASTTransformer):
def visit_Number(self, node):
s = str(node.n).encode()
return String(s, str(node.n), StringDelimiter.DOUBLE_QUOTE)

result = ArgReplacer().visit(tree)
output = ast.to_pretty_str(result)
self.assertIn("String", output)

def test_recursive_replacement(self):
"""Perform recursive transformations (expression folding)."""
src = "local x = 2 + 3"
tree = ast.parse(src)

class ConstantFolder(ast.ASTTransformer):
def visit_AddOp(self, node):
if isinstance(node.left, Number) and isinstance(node.right, Number):
return Number(node.left.n + node.right.n)
return node

result = ConstantFolder().visit(tree)
output = ast.to_lua_source(result)
self.assertIn("= 5", output)

def test_chained_replacement(self):
"""Replace a node, then replace a child of the replacement."""
src = textwrap.dedent("""\
if true then
x = 1
end
""")
tree = ast.parse(src)

class IfRewriter(ast.ASTTransformer):
def visit_If(self, node):
# Replace all if-statements with a do-block (for testing)
return Do(node.body)
def visit_TrueExpr(self, node):
# Replace true with false (tests ordering: If replaced first,
# so TrueExpr inside old If condition is never visited)
return FalseExpr()

result = IfRewriter().visit(tree)
output = ast.to_lua_source(result)
self.assertIn("do", output.lower())

def test_none_root_returns_none(self):
"""Passing None returns None."""
class NullTransformer(ast.ASTTransformer):
pass
self.assertIsNone(NullTransformer().visit(None))

def test_root_replacement(self):
"""Replace the root Chunk node."""
src = "local x = 1"
tree = ast.parse(src)

class RootReplacer(ast.ASTTransformer):
def visit_Chunk(self, node):
new_body = Block([LocalAssign(
[Name("y")],
[Number(99)]
)])
return Chunk(new_body)

result = RootReplacer().visit(tree)
output = ast.to_lua_source(result)
self.assertIn("y = 99", output)
self.assertNotIn("x = 1", output)

def test_identity_transformer_roundtrip(self):
"""Full round-trip: parse -> transform (identity) -> generate Lua."""
src = textwrap.dedent("""\
function foo(a, b)
if a > b then
return a + 1
else
return b * 2
end
end
foo(10, 20)
""")
tree = ast.parse(src)
original_output = ast.to_lua_source(tree)

class Identity(ast.ASTTransformer):
pass

result = Identity().visit(tree)
result_output = ast.to_lua_source(result)

self.assertEqual(original_output, result_output)
self.assertIs(result, tree)

def test_visit_method_for_parent_class(self):
"""visit_Name is called for Name nodes; no crash on unrecognized types."""
src = "local x = 1"
tree = ast.parse(src)

visited_names = []

class NameCollector(ast.ASTTransformer):
def visit_Name(self, node):
visited_names.append(node.id)
return node

NameCollector().visit(tree)
self.assertIn("x", visited_names)

def test_replace_deeply_nested(self):
"""Replace a node deep in the tree."""
src = textwrap.dedent("""\
local t = {
a = {
b = 42
}
}
""")
tree = ast.parse(src)

class DeepReplacer(ast.ASTTransformer):
def visit_Number(self, node):
if node.n == 42:
return Number(999)
return node

result = DeepReplacer().visit(tree)
output = ast.to_lua_source(result)
self.assertIn("999", output)
self.assertNotIn("42", output)


Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
feat: add ASTTransformer for in-place AST node replacement by boolangery · Pull Request #80 · boolangery/py-lua-parser · GitHub
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
76 changes: 76 additions & 0 deletions luaparser/ast.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -98,6 +98,82 @@ def to_pretty_json(root: Node) -> str:
return json.dumps(root, cls=JSONEncoder, indent=4)



class ASTTransformer:
"""An iterative depth-first AST visitor that supports in-place node replacement.

Subclass and override visit_<NodeType>(node) methods. Each method receives
the current node and may return:

- A new :class:`~luaparser.astnodes.Node` instance to replace the current node
- ``None`` or the same node to leave it unchanged

The transformer propagates replacements up to the parent (or returns a new
root from :meth:`visit`). After a replacement, children of the *new* node
are visited -- not the original.

Example::

class NumberDoubler(ASTTransformer):
def visit_Number(self, node):
return Number(node.value * 2)

tree = ast.parse("x = 5")
new_tree = NumberDoubler().visit(tree)
"""

def visit(self, root):
"""Transform *root* and return the (possibly new) root node.

Returns None if *root* is None.
"""
if root is None:
return None

# Each stack entry is (node, parent_info) where parent_info is either
# None (for root) or (key, container) -- container is a Node or list.
node_stack = [(root, None)]

while node_stack:
node, parent_info = node_stack.pop()

if isinstance(node, Node):
# --- call visitor ---
name = "visit_" + node.__class__.__name__
visitor_method = getattr(self, name, None)
if visitor_method is not None:
replacement = visitor_method(node)
if replacement is not None and replacement is not node:
# Replace node in parent (or update root)
if parent_info is not None:
parent_key, parent_container = parent_info
if isinstance(parent_container, list):
parent_container[parent_key] = replacement
else:
setattr(parent_container, parent_key, replacement)
else:
root = replacement
node = replacement # visit replacement's children

# --- push children (reverse order for correct DFS) ---
children = [
attr for attr in node.__dict__.keys()
if not attr.startswith("_")
]
for child_key in reversed(children):
child = node.__dict__[child_key]
if isinstance(child, list):
for i in reversed(range(len(child))):
node_stack.append((child[i], (i, child)))
elif isinstance(child, Node):
node_stack.append((child, (child_key, node)))

elif isinstance(node, list):
for n in reversed(node):
node_stack.append((n, parent_info))

return root

class ASTVisitor:
def visit(self, root):
# base case:
Expand Down
248 changes: 248 additions & 0 deletions luaparser/tests/test_ast.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -158,3 +158,251 @@ def test_to_pretty_json(self):
}"""
)
self.assertEqual(ast.to_pretty_json(ast.parse(src)), exp)


class ASTTransformerTestCase(tests.TestCase):
"""Tests for ASTTransformer."""

def test_noop_returns_same_tree(self):
"""Transformer with no overrides returns identical structure."""
src = "local x = 1"
tree = ast.parse(src)

class NoopTransformer(ast.ASTTransformer):
pass

result = NoopTransformer().visit(tree)
self.assertEqual(ast.to_pretty_str(result), ast.to_pretty_str(tree))
# Root should be the same object when no transforms applied
self.assertIs(result, tree)

def test_replace_number(self):
"""Replace a Number node with a different value."""
src = "local x = 42"
tree = ast.parse(src)

class Doubler(ast.ASTTransformer):
def visit_Number(self, node):
return Number(node.n * 2)

result = Doubler().visit(tree)
self.assertIn("84", ast.to_pretty_str(result))
self.assertNotIn("42", ast.to_pretty_str(result))

def test_replace_string(self):
"""Replace a String literal."""
src = 'local msg = "hello"'
tree = ast.parse(src)

class StringUpper(ast.ASTTransformer):
def visit_String(self, node):
upper_raw = node.raw.upper()
return String(upper_raw.encode(), upper_raw, node.delimiter)

result = StringUpper().visit(tree)
output = ast.to_pretty_str(result)
self.assertIn("HELLO", output)
self.assertNotIn("hello", output)

def test_keep_node_by_returning_none(self):
"""Returning None keeps the original node."""
src = "local x = 42"
tree = ast.parse(src)

class KeepNumbers(ast.ASTTransformer):
def visit_Number(self, node):
if node.n == 42:
return None # keep
return Number(0)

result = KeepNumbers().visit(tree)
self.assertEqual(ast.to_pretty_str(result), ast.to_pretty_str(tree))

def test_keep_node_by_returning_same(self):
"""Returning the same node object keeps it."""
src = "local x = 42"
tree = ast.parse(src)

class IdentityTransformer(ast.ASTTransformer):
def visit_Number(self, node):
return node # same object

result = IdentityTransformer().visit(tree)
self.assertEqual(ast.to_pretty_str(result), ast.to_pretty_str(tree))
self.assertIs(result, tree)

def test_replace_statement(self):
"""Replace an entire statement node."""
src = "local x = 1"
tree = ast.parse(src)

class AssignToNil(ast.ASTTransformer):
def visit_LocalAssign(self, node):
# Replace local assignment with a nil assignment
return Assign(node.targets, [Nil()])

result = AssignToNil().visit(tree)
output = ast.to_lua_source(result)
self.assertIn("nil", output)
self.assertNotIn("local", output)

def test_replace_multiple_in_list(self):
"""Replace multiple nodes in a list (block body)."""
src = textwrap.dedent("""\
local a = 1
local b = 2
local c = 3
""")
tree = ast.parse(src)

class DropMiddleStatement(ast.ASTTransformer):
def visit_LocalAssign(self, node):
target_name = node.targets[0].id
if target_name == "b":
return None # remove it -- handled via parent removal
return node

result = DropMiddleStatement().visit(tree)
output = ast.to_lua_source(result)
self.assertIn("local a", output)
self.assertIn("local c", output)
# 'local b' should still be there since returning None keeps the node
self.assertIn("local b", output)

def test_replace_in_call_args(self):
"""Replace an argument inside a function call."""
src = "print(42)"
tree = ast.parse(src)

class ArgReplacer(ast.ASTTransformer):
def visit_Number(self, node):
s = str(node.n).encode()
return String(s, str(node.n), StringDelimiter.DOUBLE_QUOTE)

result = ArgReplacer().visit(tree)
output = ast.to_pretty_str(result)
self.assertIn("String", output)

def test_recursive_replacement(self):
"""Perform recursive transformations (expression folding)."""
src = "local x = 2 + 3"
tree = ast.parse(src)

class ConstantFolder(ast.ASTTransformer):
def visit_AddOp(self, node):
if isinstance(node.left, Number) and isinstance(node.right, Number):
return Number(node.left.n + node.right.n)
return node

result = ConstantFolder().visit(tree)
output = ast.to_lua_source(result)
self.assertIn("= 5", output)

def test_chained_replacement(self):
"""Replace a node, then replace a child of the replacement."""
src = textwrap.dedent("""\
if true then
x = 1
end
""")
tree = ast.parse(src)

class IfRewriter(ast.ASTTransformer):
def visit_If(self, node):
# Replace all if-statements with a do-block (for testing)
return Do(node.body)
def visit_TrueExpr(self, node):
# Replace true with false (tests ordering: If replaced first,
# so TrueExpr inside old If condition is never visited)
return FalseExpr()

result = IfRewriter().visit(tree)
output = ast.to_lua_source(result)
self.assertIn("do", output.lower())

def test_none_root_returns_none(self):
"""Passing None returns None."""
class NullTransformer(ast.ASTTransformer):
pass
self.assertIsNone(NullTransformer().visit(None))

def test_root_replacement(self):
"""Replace the root Chunk node."""
src = "local x = 1"
tree = ast.parse(src)

class RootReplacer(ast.ASTTransformer):
def visit_Chunk(self, node):
new_body = Block([LocalAssign(
[Name("y")],
[Number(99)]
)])
return Chunk(new_body)

result = RootReplacer().visit(tree)
output = ast.to_lua_source(result)
self.assertIn("y = 99", output)
self.assertNotIn("x = 1", output)

def test_identity_transformer_roundtrip(self):
"""Full round-trip: parse -> transform (identity) -> generate Lua."""
src = textwrap.dedent("""\
function foo(a, b)
if a > b then
return a + 1
else
return b * 2
end
end
foo(10, 20)
""")
tree = ast.parse(src)
original_output = ast.to_lua_source(tree)

class Identity(ast.ASTTransformer):
pass

result = Identity().visit(tree)
result_output = ast.to_lua_source(result)

self.assertEqual(original_output, result_output)
self.assertIs(result, tree)

def test_visit_method_for_parent_class(self):
"""visit_Name is called for Name nodes; no crash on unrecognized types."""
src = "local x = 1"
tree = ast.parse(src)

visited_names = []

class NameCollector(ast.ASTTransformer):
def visit_Name(self, node):
visited_names.append(node.id)
return node

NameCollector().visit(tree)
self.assertIn("x", visited_names)

def test_replace_deeply_nested(self):
"""Replace a node deep in the tree."""
src = textwrap.dedent("""\
local t = {
a = {
b = 42
}
}
""")
tree = ast.parse(src)

class DeepReplacer(ast.ASTTransformer):
def visit_Number(self, node):
if node.n == 42:
return Number(999)
return node

result = DeepReplacer().visit(tree)
output = ast.to_lua_source(result)
self.assertIn("999", output)
self.assertNotIn("42", output)


Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat: add ASTTransformer for in-place AST node replacement by boolangery · Pull Request #80 · boolangery/py-lua-parser · GitHub
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
76 changes: 76 additions & 0 deletions luaparser/ast.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -98,6 +98,82 @@ def to_pretty_json(root: Node) -> str:
return json.dumps(root, cls=JSONEncoder, indent=4)



class ASTTransformer:
"""An iterative depth-first AST visitor that supports in-place node replacement.

Subclass and override visit_<NodeType>(node) methods. Each method receives
the current node and may return:

- A new :class:`~luaparser.astnodes.Node` instance to replace the current node
- ``None`` or the same node to leave it unchanged

The transformer propagates replacements up to the parent (or returns a new
root from :meth:`visit`). After a replacement, children of the *new* node
are visited -- not the original.

Example::

class NumberDoubler(ASTTransformer):
def visit_Number(self, node):
return Number(node.value * 2)

tree = ast.parse("x = 5")
new_tree = NumberDoubler().visit(tree)
"""

def visit(self, root):
"""Transform *root* and return the (possibly new) root node.

Returns None if *root* is None.
"""
if root is None:
return None

# Each stack entry is (node, parent_info) where parent_info is either
# None (for root) or (key, container) -- container is a Node or list.
node_stack = [(root, None)]

while node_stack:
node, parent_info = node_stack.pop()

if isinstance(node, Node):
# --- call visitor ---
name = "visit_" + node.__class__.__name__
visitor_method = getattr(self, name, None)
if visitor_method is not None:
replacement = visitor_method(node)
if replacement is not None and replacement is not node:
# Replace node in parent (or update root)
if parent_info is not None:
parent_key, parent_container = parent_info
if isinstance(parent_container, list):
parent_container[parent_key] = replacement
else:
setattr(parent_container, parent_key, replacement)
else:
root = replacement
node = replacement # visit replacement's children

# --- push children (reverse order for correct DFS) ---
children = [
attr for attr in node.__dict__.keys()
if not attr.startswith("_")
]
for child_key in reversed(children):
child = node.__dict__[child_key]
if isinstance(child, list):
for i in reversed(range(len(child))):
node_stack.append((child[i], (i, child)))
elif isinstance(child, Node):
node_stack.append((child, (child_key, node)))

elif isinstance(node, list):
for n in reversed(node):
node_stack.append((n, parent_info))

return root

class ASTVisitor:
def visit(self, root):
# base case:
Expand Down
248 changes: 248 additions & 0 deletions luaparser/tests/test_ast.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -158,3 +158,251 @@ def test_to_pretty_json(self):
}"""
)
self.assertEqual(ast.to_pretty_json(ast.parse(src)), exp)


class ASTTransformerTestCase(tests.TestCase):
"""Tests for ASTTransformer."""

def test_noop_returns_same_tree(self):
"""Transformer with no overrides returns identical structure."""
src = "local x = 1"
tree = ast.parse(src)

class NoopTransformer(ast.ASTTransformer):
pass

result = NoopTransformer().visit(tree)
self.assertEqual(ast.to_pretty_str(result), ast.to_pretty_str(tree))
# Root should be the same object when no transforms applied
self.assertIs(result, tree)

def test_replace_number(self):
"""Replace a Number node with a different value."""
src = "local x = 42"
tree = ast.parse(src)

class Doubler(ast.ASTTransformer):
def visit_Number(self, node):
return Number(node.n * 2)

result = Doubler().visit(tree)
self.assertIn("84", ast.to_pretty_str(result))
self.assertNotIn("42", ast.to_pretty_str(result))

def test_replace_string(self):
"""Replace a String literal."""
src = 'local msg = "hello"'
tree = ast.parse(src)

class StringUpper(ast.ASTTransformer):
def visit_String(self, node):
upper_raw = node.raw.upper()
return String(upper_raw.encode(), upper_raw, node.delimiter)

result = StringUpper().visit(tree)
output = ast.to_pretty_str(result)
self.assertIn("HELLO", output)
self.assertNotIn("hello", output)

def test_keep_node_by_returning_none(self):
"""Returning None keeps the original node."""
src = "local x = 42"
tree = ast.parse(src)

class KeepNumbers(ast.ASTTransformer):
def visit_Number(self, node):
if node.n == 42:
return None # keep
return Number(0)

result = KeepNumbers().visit(tree)
self.assertEqual(ast.to_pretty_str(result), ast.to_pretty_str(tree))

def test_keep_node_by_returning_same(self):
"""Returning the same node object keeps it."""
src = "local x = 42"
tree = ast.parse(src)

class IdentityTransformer(ast.ASTTransformer):
def visit_Number(self, node):
return node # same object

result = IdentityTransformer().visit(tree)
self.assertEqual(ast.to_pretty_str(result), ast.to_pretty_str(tree))
self.assertIs(result, tree)

def test_replace_statement(self):
"""Replace an entire statement node."""
src = "local x = 1"
tree = ast.parse(src)

class AssignToNil(ast.ASTTransformer):
def visit_LocalAssign(self, node):
# Replace local assignment with a nil assignment
return Assign(node.targets, [Nil()])

result = AssignToNil().visit(tree)
output = ast.to_lua_source(result)
self.assertIn("nil", output)
self.assertNotIn("local", output)

def test_replace_multiple_in_list(self):
"""Replace multiple nodes in a list (block body)."""
src = textwrap.dedent("""\
local a = 1
local b = 2
local c = 3
""")
tree = ast.parse(src)

class DropMiddleStatement(ast.ASTTransformer):
def visit_LocalAssign(self, node):
target_name = node.targets[0].id
if target_name == "b":
return None # remove it -- handled via parent removal
return node

result = DropMiddleStatement().visit(tree)
output = ast.to_lua_source(result)
self.assertIn("local a", output)
self.assertIn("local c", output)
# 'local b' should still be there since returning None keeps the node
self.assertIn("local b", output)

def test_replace_in_call_args(self):
"""Replace an argument inside a function call."""
src = "print(42)"
tree = ast.parse(src)

class ArgReplacer(ast.ASTTransformer):
def visit_Number(self, node):
s = str(node.n).encode()
return String(s, str(node.n), StringDelimiter.DOUBLE_QUOTE)

result = ArgReplacer().visit(tree)
output = ast.to_pretty_str(result)
self.assertIn("String", output)

def test_recursive_replacement(self):
"""Perform recursive transformations (expression folding)."""
src = "local x = 2 + 3"
tree = ast.parse(src)

class ConstantFolder(ast.ASTTransformer):
def visit_AddOp(self, node):
if isinstance(node.left, Number) and isinstance(node.right, Number):
return Number(node.left.n + node.right.n)
return node

result = ConstantFolder().visit(tree)
output = ast.to_lua_source(result)
self.assertIn("= 5", output)

def test_chained_replacement(self):
"""Replace a node, then replace a child of the replacement."""
src = textwrap.dedent("""\
if true then
x = 1
end
""")
tree = ast.parse(src)

class IfRewriter(ast.ASTTransformer):
def visit_If(self, node):
# Replace all if-statements with a do-block (for testing)
return Do(node.body)
def visit_TrueExpr(self, node):
# Replace true with false (tests ordering: If replaced first,
# so TrueExpr inside old If condition is never visited)
return FalseExpr()

result = IfRewriter().visit(tree)
output = ast.to_lua_source(result)
self.assertIn("do", output.lower())

def test_none_root_returns_none(self):
"""Passing None returns None."""
class NullTransformer(ast.ASTTransformer):
pass
self.assertIsNone(NullTransformer().visit(None))

def test_root_replacement(self):
"""Replace the root Chunk node."""
src = "local x = 1"
tree = ast.parse(src)

class RootReplacer(ast.ASTTransformer):
def visit_Chunk(self, node):
new_body = Block([LocalAssign(
[Name("y")],
[Number(99)]
)])
return Chunk(new_body)

result = RootReplacer().visit(tree)
output = ast.to_lua_source(result)
self.assertIn("y = 99", output)
self.assertNotIn("x = 1", output)

def test_identity_transformer_roundtrip(self):
"""Full round-trip: parse -> transform (identity) -> generate Lua."""
src = textwrap.dedent("""\
function foo(a, b)
if a > b then
return a + 1
else
return b * 2
end
end
foo(10, 20)
""")
tree = ast.parse(src)
original_output = ast.to_lua_source(tree)

class Identity(ast.ASTTransformer):
pass

result = Identity().visit(tree)
result_output = ast.to_lua_source(result)

self.assertEqual(original_output, result_output)
self.assertIs(result, tree)

def test_visit_method_for_parent_class(self):
"""visit_Name is called for Name nodes; no crash on unrecognized types."""
src = "local x = 1"
tree = ast.parse(src)

visited_names = []

class NameCollector(ast.ASTTransformer):
def visit_Name(self, node):
visited_names.append(node.id)
return node

NameCollector().visit(tree)
self.assertIn("x", visited_names)

def test_replace_deeply_nested(self):
"""Replace a node deep in the tree."""
src = textwrap.dedent("""\
local t = {
a = {
b = 42
}
}
""")
tree = ast.parse(src)

class DeepReplacer(ast.ASTTransformer):
def visit_Number(self, node):
if node.n == 42:
return Number(999)
return node

result = DeepReplacer().visit(tree)
output = ast.to_lua_source(result)
self.assertIn("999", output)
self.assertNotIn("42", output)


Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat: add ASTTransformer for in-place AST node replacement by boolangery · Pull Request #80 · boolangery/py-lua-parser · GitHub
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
76 changes: 76 additions & 0 deletions luaparser/ast.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -98,6 +98,82 @@ def to_pretty_json(root: Node) -> str:
return json.dumps(root, cls=JSONEncoder, indent=4)



class ASTTransformer:
"""An iterative depth-first AST visitor that supports in-place node replacement.

Subclass and override visit_<NodeType>(node) methods. Each method receives
the current node and may return:

- A new :class:`~luaparser.astnodes.Node` instance to replace the current node
- ``None`` or the same node to leave it unchanged

The transformer propagates replacements up to the parent (or returns a new
root from :meth:`visit`). After a replacement, children of the *new* node
are visited -- not the original.

Example::

class NumberDoubler(ASTTransformer):
def visit_Number(self, node):
return Number(node.value * 2)

tree = ast.parse("x = 5")
new_tree = NumberDoubler().visit(tree)
"""

def visit(self, root):
"""Transform *root* and return the (possibly new) root node.

Returns None if *root* is None.
"""
if root is None:
return None

# Each stack entry is (node, parent_info) where parent_info is either
# None (for root) or (key, container) -- container is a Node or list.
node_stack = [(root, None)]

while node_stack:
node, parent_info = node_stack.pop()

if isinstance(node, Node):
# --- call visitor ---
name = "visit_" + node.__class__.__name__
visitor_method = getattr(self, name, None)
if visitor_method is not None:
replacement = visitor_method(node)
if replacement is not None and replacement is not node:
# Replace node in parent (or update root)
if parent_info is not None:
parent_key, parent_container = parent_info
if isinstance(parent_container, list):
parent_container[parent_key] = replacement
else:
setattr(parent_container, parent_key, replacement)
else:
root = replacement
node = replacement # visit replacement's children

# --- push children (reverse order for correct DFS) ---
children = [
attr for attr in node.__dict__.keys()
if not attr.startswith("_")
]
for child_key in reversed(children):
child = node.__dict__[child_key]
if isinstance(child, list):
for i in reversed(range(len(child))):
node_stack.append((child[i], (i, child)))
elif isinstance(child, Node):
node_stack.append((child, (child_key, node)))

elif isinstance(node, list):
for n in reversed(node):
node_stack.append((n, parent_info))

return root

class ASTVisitor:
def visit(self, root):
# base case:
Expand Down
248 changes: 248 additions & 0 deletions luaparser/tests/test_ast.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -158,3 +158,251 @@ def test_to_pretty_json(self):
}"""
)
self.assertEqual(ast.to_pretty_json(ast.parse(src)), exp)


class ASTTransformerTestCase(tests.TestCase):
"""Tests for ASTTransformer."""

def test_noop_returns_same_tree(self):
"""Transformer with no overrides returns identical structure."""
src = "local x = 1"
tree = ast.parse(src)

class NoopTransformer(ast.ASTTransformer):
pass

result = NoopTransformer().visit(tree)
self.assertEqual(ast.to_pretty_str(result), ast.to_pretty_str(tree))
# Root should be the same object when no transforms applied
self.assertIs(result, tree)

def test_replace_number(self):
"""Replace a Number node with a different value."""
src = "local x = 42"
tree = ast.parse(src)

class Doubler(ast.ASTTransformer):
def visit_Number(self, node):
return Number(node.n * 2)

result = Doubler().visit(tree)
self.assertIn("84", ast.to_pretty_str(result))
self.assertNotIn("42", ast.to_pretty_str(result))

def test_replace_string(self):
"""Replace a String literal."""
src = 'local msg = "hello"'
tree = ast.parse(src)

class StringUpper(ast.ASTTransformer):
def visit_String(self, node):
upper_raw = node.raw.upper()
return String(upper_raw.encode(), upper_raw, node.delimiter)

result = StringUpper().visit(tree)
output = ast.to_pretty_str(result)
self.assertIn("HELLO", output)
self.assertNotIn("hello", output)

def test_keep_node_by_returning_none(self):
"""Returning None keeps the original node."""
src = "local x = 42"
tree = ast.parse(src)

class KeepNumbers(ast.ASTTransformer):
def visit_Number(self, node):
if node.n == 42:
return None # keep
return Number(0)

result = KeepNumbers().visit(tree)
self.assertEqual(ast.to_pretty_str(result), ast.to_pretty_str(tree))

def test_keep_node_by_returning_same(self):
"""Returning the same node object keeps it."""
src = "local x = 42"
tree = ast.parse(src)

class IdentityTransformer(ast.ASTTransformer):
def visit_Number(self, node):
return node # same object

result = IdentityTransformer().visit(tree)
self.assertEqual(ast.to_pretty_str(result), ast.to_pretty_str(tree))
self.assertIs(result, tree)

def test_replace_statement(self):
"""Replace an entire statement node."""
src = "local x = 1"
tree = ast.parse(src)

class AssignToNil(ast.ASTTransformer):
def visit_LocalAssign(self, node):
# Replace local assignment with a nil assignment
return Assign(node.targets, [Nil()])

result = AssignToNil().visit(tree)
output = ast.to_lua_source(result)
self.assertIn("nil", output)
self.assertNotIn("local", output)

def test_replace_multiple_in_list(self):
"""Replace multiple nodes in a list (block body)."""
src = textwrap.dedent("""\
local a = 1
local b = 2
local c = 3
""")
tree = ast.parse(src)

class DropMiddleStatement(ast.ASTTransformer):
def visit_LocalAssign(self, node):
target_name = node.targets[0].id
if target_name == "b":
return None # remove it -- handled via parent removal
return node

result = DropMiddleStatement().visit(tree)
output = ast.to_lua_source(result)
self.assertIn("local a", output)
self.assertIn("local c", output)
# 'local b' should still be there since returning None keeps the node
self.assertIn("local b", output)

def test_replace_in_call_args(self):
"""Replace an argument inside a function call."""
src = "print(42)"
tree = ast.parse(src)

class ArgReplacer(ast.ASTTransformer):
def visit_Number(self, node):
s = str(node.n).encode()
return String(s, str(node.n), StringDelimiter.DOUBLE_QUOTE)

result = ArgReplacer().visit(tree)
output = ast.to_pretty_str(result)
self.assertIn("String", output)

def test_recursive_replacement(self):
"""Perform recursive transformations (expression folding)."""
src = "local x = 2 + 3"
tree = ast.parse(src)

class ConstantFolder(ast.ASTTransformer):
def visit_AddOp(self, node):
if isinstance(node.left, Number) and isinstance(node.right, Number):
return Number(node.left.n + node.right.n)
return node

result = ConstantFolder().visit(tree)
output = ast.to_lua_source(result)
self.assertIn("= 5", output)

def test_chained_replacement(self):
"""Replace a node, then replace a child of the replacement."""
src = textwrap.dedent("""\
if true then
x = 1
end
""")
tree = ast.parse(src)

class IfRewriter(ast.ASTTransformer):
def visit_If(self, node):
# Replace all if-statements with a do-block (for testing)
return Do(node.body)
def visit_TrueExpr(self, node):
# Replace true with false (tests ordering: If replaced first,
# so TrueExpr inside old If condition is never visited)
return FalseExpr()

result = IfRewriter().visit(tree)
output = ast.to_lua_source(result)
self.assertIn("do", output.lower())

def test_none_root_returns_none(self):
"""Passing None returns None."""
class NullTransformer(ast.ASTTransformer):
pass
self.assertIsNone(NullTransformer().visit(None))

def test_root_replacement(self):
"""Replace the root Chunk node."""
src = "local x = 1"
tree = ast.parse(src)

class RootReplacer(ast.ASTTransformer):
def visit_Chunk(self, node):
new_body = Block([LocalAssign(
[Name("y")],
[Number(99)]
)])
return Chunk(new_body)

result = RootReplacer().visit(tree)
output = ast.to_lua_source(result)
self.assertIn("y = 99", output)
self.assertNotIn("x = 1", output)

def test_identity_transformer_roundtrip(self):
"""Full round-trip: parse -> transform (identity) -> generate Lua."""
src = textwrap.dedent("""\
function foo(a, b)
if a > b then
return a + 1
else
return b * 2
end
end
foo(10, 20)
""")
tree = ast.parse(src)
original_output = ast.to_lua_source(tree)

class Identity(ast.ASTTransformer):
pass

result = Identity().visit(tree)
result_output = ast.to_lua_source(result)

self.assertEqual(original_output, result_output)
self.assertIs(result, tree)

def test_visit_method_for_parent_class(self):
"""visit_Name is called for Name nodes; no crash on unrecognized types."""
src = "local x = 1"
tree = ast.parse(src)

visited_names = []

class NameCollector(ast.ASTTransformer):
def visit_Name(self, node):
visited_names.append(node.id)
return node

NameCollector().visit(tree)
self.assertIn("x", visited_names)

def test_replace_deeply_nested(self):
"""Replace a node deep in the tree."""
src = textwrap.dedent("""\
local t = {
a = {
b = 42
}
}
""")
tree = ast.parse(src)

class DeepReplacer(ast.ASTTransformer):
def visit_Number(self, node):
if node.n == 42:
return Number(999)
return node

result = DeepReplacer().visit(tree)
output = ast.to_lua_source(result)
self.assertIn("999", output)
self.assertNotIn("42", output)


Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' feat: add ASTTransformer for in-place AST node replacement by boolangery · Pull Request #80 · boolangery/py-lua-parser · GitHub
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
76 changes: 76 additions & 0 deletions luaparser/ast.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -98,6 +98,82 @@ def to_pretty_json(root: Node) -> str:
return json.dumps(root, cls=JSONEncoder, indent=4)



class ASTTransformer:
"""An iterative depth-first AST visitor that supports in-place node replacement.

Subclass and override visit_<NodeType>(node) methods. Each method receives
the current node and may return:

- A new :class:`~luaparser.astnodes.Node` instance to replace the current node
- ``None`` or the same node to leave it unchanged

The transformer propagates replacements up to the parent (or returns a new
root from :meth:`visit`). After a replacement, children of the *new* node
are visited -- not the original.

Example::

class NumberDoubler(ASTTransformer):
def visit_Number(self, node):
return Number(node.value * 2)

tree = ast.parse("x = 5")
new_tree = NumberDoubler().visit(tree)
"""

def visit(self, root):
"""Transform *root* and return the (possibly new) root node.

Returns None if *root* is None.
"""
if root is None:
return None

# Each stack entry is (node, parent_info) where parent_info is either
# None (for root) or (key, container) -- container is a Node or list.
node_stack = [(root, None)]

while node_stack:
node, parent_info = node_stack.pop()

if isinstance(node, Node):
# --- call visitor ---
name = "visit_" + node.__class__.__name__
visitor_method = getattr(self, name, None)
if visitor_method is not None:
replacement = visitor_method(node)
if replacement is not None and replacement is not node:
# Replace node in parent (or update root)
if parent_info is not None:
parent_key, parent_container = parent_info
if isinstance(parent_container, list):
parent_container[parent_key] = replacement
else:
setattr(parent_container, parent_key, replacement)
else:
root = replacement
node = replacement # visit replacement's children

# --- push children (reverse order for correct DFS) ---
children = [
attr for attr in node.__dict__.keys()
if not attr.startswith("_")
]
for child_key in reversed(children):
child = node.__dict__[child_key]
if isinstance(child, list):
for i in reversed(range(len(child))):
node_stack.append((child[i], (i, child)))
elif isinstance(child, Node):
node_stack.append((child, (child_key, node)))

elif isinstance(node, list):
for n in reversed(node):
node_stack.append((n, parent_info))

return root

class ASTVisitor:
def visit(self, root):
# base case:
Expand Down
248 changes: 248 additions & 0 deletions luaparser/tests/test_ast.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -158,3 +158,251 @@ def test_to_pretty_json(self):
}"""
)
self.assertEqual(ast.to_pretty_json(ast.parse(src)), exp)


class ASTTransformerTestCase(tests.TestCase):
"""Tests for ASTTransformer."""

def test_noop_returns_same_tree(self):
"""Transformer with no overrides returns identical structure."""
src = "local x = 1"
tree = ast.parse(src)

class NoopTransformer(ast.ASTTransformer):
pass

result = NoopTransformer().visit(tree)
self.assertEqual(ast.to_pretty_str(result), ast.to_pretty_str(tree))
# Root should be the same object when no transforms applied
self.assertIs(result, tree)

def test_replace_number(self):
"""Replace a Number node with a different value."""
src = "local x = 42"
tree = ast.parse(src)

class Doubler(ast.ASTTransformer):
def visit_Number(self, node):
return Number(node.n * 2)

result = Doubler().visit(tree)
self.assertIn("84", ast.to_pretty_str(result))
self.assertNotIn("42", ast.to_pretty_str(result))

def test_replace_string(self):
"""Replace a String literal."""
src = 'local msg = "hello"'
tree = ast.parse(src)

class StringUpper(ast.ASTTransformer):
def visit_String(self, node):
upper_raw = node.raw.upper()
return String(upper_raw.encode(), upper_raw, node.delimiter)

result = StringUpper().visit(tree)
output = ast.to_pretty_str(result)
self.assertIn("HELLO", output)
self.assertNotIn("hello", output)

def test_keep_node_by_returning_none(self):
"""Returning None keeps the original node."""
src = "local x = 42"
tree = ast.parse(src)

class KeepNumbers(ast.ASTTransformer):
def visit_Number(self, node):
if node.n == 42:
return None # keep
return Number(0)

result = KeepNumbers().visit(tree)
self.assertEqual(ast.to_pretty_str(result), ast.to_pretty_str(tree))

def test_keep_node_by_returning_same(self):
"""Returning the same node object keeps it."""
src = "local x = 42"
tree = ast.parse(src)

class IdentityTransformer(ast.ASTTransformer):
def visit_Number(self, node):
return node # same object

result = IdentityTransformer().visit(tree)
self.assertEqual(ast.to_pretty_str(result), ast.to_pretty_str(tree))
self.assertIs(result, tree)

def test_replace_statement(self):
"""Replace an entire statement node."""
src = "local x = 1"
tree = ast.parse(src)

class AssignToNil(ast.ASTTransformer):
def visit_LocalAssign(self, node):
# Replace local assignment with a nil assignment
return Assign(node.targets, [Nil()])

result = AssignToNil().visit(tree)
output = ast.to_lua_source(result)
self.assertIn("nil", output)
self.assertNotIn("local", output)

def test_replace_multiple_in_list(self):
"""Replace multiple nodes in a list (block body)."""
src = textwrap.dedent("""\
local a = 1
local b = 2
local c = 3
""")
tree = ast.parse(src)

class DropMiddleStatement(ast.ASTTransformer):
def visit_LocalAssign(self, node):
target_name = node.targets[0].id
if target_name == "b":
return None # remove it -- handled via parent removal
return node

result = DropMiddleStatement().visit(tree)
output = ast.to_lua_source(result)
self.assertIn("local a", output)
self.assertIn("local c", output)
# 'local b' should still be there since returning None keeps the node
self.assertIn("local b", output)

def test_replace_in_call_args(self):
"""Replace an argument inside a function call."""
src = "print(42)"
tree = ast.parse(src)

class ArgReplacer(ast.ASTTransformer):
def visit_Number(self, node):
s = str(node.n).encode()
return String(s, str(node.n), StringDelimiter.DOUBLE_QUOTE)

result = ArgReplacer().visit(tree)
output = ast.to_pretty_str(result)
self.assertIn("String", output)

def test_recursive_replacement(self):
"""Perform recursive transformations (expression folding)."""
src = "local x = 2 + 3"
tree = ast.parse(src)

class ConstantFolder(ast.ASTTransformer):
def visit_AddOp(self, node):
if isinstance(node.left, Number) and isinstance(node.right, Number):
return Number(node.left.n + node.right.n)
return node

result = ConstantFolder().visit(tree)
output = ast.to_lua_source(result)
self.assertIn("= 5", output)

def test_chained_replacement(self):
"""Replace a node, then replace a child of the replacement."""
src = textwrap.dedent("""\
if true then
x = 1
end
""")
tree = ast.parse(src)

class IfRewriter(ast.ASTTransformer):
def visit_If(self, node):
# Replace all if-statements with a do-block (for testing)
return Do(node.body)
def visit_TrueExpr(self, node):
# Replace true with false (tests ordering: If replaced first,
# so TrueExpr inside old If condition is never visited)
return FalseExpr()

result = IfRewriter().visit(tree)
output = ast.to_lua_source(result)
self.assertIn("do", output.lower())

def test_none_root_returns_none(self):
"""Passing None returns None."""
class NullTransformer(ast.ASTTransformer):
pass
self.assertIsNone(NullTransformer().visit(None))

def test_root_replacement(self):
"""Replace the root Chunk node."""
src = "local x = 1"
tree = ast.parse(src)

class RootReplacer(ast.ASTTransformer):
def visit_Chunk(self, node):
new_body = Block([LocalAssign(
[Name("y")],
[Number(99)]
)])
return Chunk(new_body)

result = RootReplacer().visit(tree)
output = ast.to_lua_source(result)
self.assertIn("y = 99", output)
self.assertNotIn("x = 1", output)

def test_identity_transformer_roundtrip(self):
"""Full round-trip: parse -> transform (identity) -> generate Lua."""
src = textwrap.dedent("""\
function foo(a, b)
if a > b then
return a + 1
else
return b * 2
end
end
foo(10, 20)
""")
tree = ast.parse(src)
original_output = ast.to_lua_source(tree)

class Identity(ast.ASTTransformer):
pass

result = Identity().visit(tree)
result_output = ast.to_lua_source(result)

self.assertEqual(original_output, result_output)
self.assertIs(result, tree)

def test_visit_method_for_parent_class(self):
"""visit_Name is called for Name nodes; no crash on unrecognized types."""
src = "local x = 1"
tree = ast.parse(src)

visited_names = []

class NameCollector(ast.ASTTransformer):
def visit_Name(self, node):
visited_names.append(node.id)
return node

NameCollector().visit(tree)
self.assertIn("x", visited_names)

def test_replace_deeply_nested(self):
"""Replace a node deep in the tree."""
src = textwrap.dedent("""\
local t = {
a = {
b = 42
}
}
""")
tree = ast.parse(src)

class DeepReplacer(ast.ASTTransformer):
def visit_Number(self, node):
if node.n == 42:
return Number(999)
return node

result = DeepReplacer().visit(tree)
output = ast.to_lua_source(result)
self.assertIn("999", output)
self.assertNotIn("42", output)


Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat: add ASTTransformer for in-place AST node replacement by boolangery · Pull Request #80 · boolangery/py-lua-parser · GitHub
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
76 changes: 76 additions & 0 deletions luaparser/ast.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -98,6 +98,82 @@ def to_pretty_json(root: Node) -> str:
return json.dumps(root, cls=JSONEncoder, indent=4)



class ASTTransformer:
"""An iterative depth-first AST visitor that supports in-place node replacement.

Subclass and override visit_<NodeType>(node) methods. Each method receives
the current node and may return:

- A new :class:`~luaparser.astnodes.Node` instance to replace the current node
- ``None`` or the same node to leave it unchanged

The transformer propagates replacements up to the parent (or returns a new
root from :meth:`visit`). After a replacement, children of the *new* node
are visited -- not the original.

Example::

class NumberDoubler(ASTTransformer):
def visit_Number(self, node):
return Number(node.value * 2)

tree = ast.parse("x = 5")
new_tree = NumberDoubler().visit(tree)
"""

def visit(self, root):
"""Transform *root* and return the (possibly new) root node.

Returns None if *root* is None.
"""
if root is None:
return None

# Each stack entry is (node, parent_info) where parent_info is either
# None (for root) or (key, container) -- container is a Node or list.
node_stack = [(root, None)]

while node_stack:
node, parent_info = node_stack.pop()

if isinstance(node, Node):
# --- call visitor ---
name = "visit_" + node.__class__.__name__
visitor_method = getattr(self, name, None)
if visitor_method is not None:
replacement = visitor_method(node)
if replacement is not None and replacement is not node:
# Replace node in parent (or update root)
if parent_info is not None:
parent_key, parent_container = parent_info
if isinstance(parent_container, list):
parent_container[parent_key] = replacement
else:
setattr(parent_container, parent_key, replacement)
else:
root = replacement
node = replacement # visit replacement's children

# --- push children (reverse order for correct DFS) ---
children = [
attr for attr in node.__dict__.keys()
if not attr.startswith("_")
]
for child_key in reversed(children):
child = node.__dict__[child_key]
if isinstance(child, list):
for i in reversed(range(len(child))):
node_stack.append((child[i], (i, child)))
elif isinstance(child, Node):
node_stack.append((child, (child_key, node)))

elif isinstance(node, list):
for n in reversed(node):
node_stack.append((n, parent_info))

return root

class ASTVisitor:
def visit(self, root):
# base case:
Expand Down
248 changes: 248 additions & 0 deletions luaparser/tests/test_ast.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -158,3 +158,251 @@ def test_to_pretty_json(self):
}"""
)
self.assertEqual(ast.to_pretty_json(ast.parse(src)), exp)


class ASTTransformerTestCase(tests.TestCase):
"""Tests for ASTTransformer."""

def test_noop_returns_same_tree(self):
"""Transformer with no overrides returns identical structure."""
src = "local x = 1"
tree = ast.parse(src)

class NoopTransformer(ast.ASTTransformer):
pass

result = NoopTransformer().visit(tree)
self.assertEqual(ast.to_pretty_str(result), ast.to_pretty_str(tree))
# Root should be the same object when no transforms applied
self.assertIs(result, tree)

def test_replace_number(self):
"""Replace a Number node with a different value."""
src = "local x = 42"
tree = ast.parse(src)

class Doubler(ast.ASTTransformer):
def visit_Number(self, node):
return Number(node.n * 2)

result = Doubler().visit(tree)
self.assertIn("84", ast.to_pretty_str(result))
self.assertNotIn("42", ast.to_pretty_str(result))

def test_replace_string(self):
"""Replace a String literal."""
src = 'local msg = "hello"'
tree = ast.parse(src)

class StringUpper(ast.ASTTransformer):
def visit_String(self, node):
upper_raw = node.raw.upper()
return String(upper_raw.encode(), upper_raw, node.delimiter)

result = StringUpper().visit(tree)
output = ast.to_pretty_str(result)
self.assertIn("HELLO", output)
self.assertNotIn("hello", output)

def test_keep_node_by_returning_none(self):
"""Returning None keeps the original node."""
src = "local x = 42"
tree = ast.parse(src)

class KeepNumbers(ast.ASTTransformer):
def visit_Number(self, node):
if node.n == 42:
return None # keep
return Number(0)

result = KeepNumbers().visit(tree)
self.assertEqual(ast.to_pretty_str(result), ast.to_pretty_str(tree))

def test_keep_node_by_returning_same(self):
"""Returning the same node object keeps it."""
src = "local x = 42"
tree = ast.parse(src)

class IdentityTransformer(ast.ASTTransformer):
def visit_Number(self, node):
return node # same object

result = IdentityTransformer().visit(tree)
self.assertEqual(ast.to_pretty_str(result), ast.to_pretty_str(tree))
self.assertIs(result, tree)

def test_replace_statement(self):
"""Replace an entire statement node."""
src = "local x = 1"
tree = ast.parse(src)

class AssignToNil(ast.ASTTransformer):
def visit_LocalAssign(self, node):
# Replace local assignment with a nil assignment
return Assign(node.targets, [Nil()])

result = AssignToNil().visit(tree)
output = ast.to_lua_source(result)
self.assertIn("nil", output)
self.assertNotIn("local", output)

def test_replace_multiple_in_list(self):
"""Replace multiple nodes in a list (block body)."""
src = textwrap.dedent("""\
local a = 1
local b = 2
local c = 3
""")
tree = ast.parse(src)

class DropMiddleStatement(ast.ASTTransformer):
def visit_LocalAssign(self, node):
target_name = node.targets[0].id
if target_name == "b":
return None # remove it -- handled via parent removal
return node

result = DropMiddleStatement().visit(tree)
output = ast.to_lua_source(result)
self.assertIn("local a", output)
self.assertIn("local c", output)
# 'local b' should still be there since returning None keeps the node
self.assertIn("local b", output)

def test_replace_in_call_args(self):
"""Replace an argument inside a function call."""
src = "print(42)"
tree = ast.parse(src)

class ArgReplacer(ast.ASTTransformer):
def visit_Number(self, node):
s = str(node.n).encode()
return String(s, str(node.n), StringDelimiter.DOUBLE_QUOTE)

result = ArgReplacer().visit(tree)
output = ast.to_pretty_str(result)
self.assertIn("String", output)

def test_recursive_replacement(self):
"""Perform recursive transformations (expression folding)."""
src = "local x = 2 + 3"
tree = ast.parse(src)

class ConstantFolder(ast.ASTTransformer):
def visit_AddOp(self, node):
if isinstance(node.left, Number) and isinstance(node.right, Number):
return Number(node.left.n + node.right.n)
return node

result = ConstantFolder().visit(tree)
output = ast.to_lua_source(result)
self.assertIn("= 5", output)

def test_chained_replacement(self):
"""Replace a node, then replace a child of the replacement."""
src = textwrap.dedent("""\
if true then
x = 1
end
""")
tree = ast.parse(src)

class IfRewriter(ast.ASTTransformer):
def visit_If(self, node):
# Replace all if-statements with a do-block (for testing)
return Do(node.body)
def visit_TrueExpr(self, node):
# Replace true with false (tests ordering: If replaced first,
# so TrueExpr inside old If condition is never visited)
return FalseExpr()

result = IfRewriter().visit(tree)
output = ast.to_lua_source(result)
self.assertIn("do", output.lower())

def test_none_root_returns_none(self):
"""Passing None returns None."""
class NullTransformer(ast.ASTTransformer):
pass
self.assertIsNone(NullTransformer().visit(None))

def test_root_replacement(self):
"""Replace the root Chunk node."""
src = "local x = 1"
tree = ast.parse(src)

class RootReplacer(ast.ASTTransformer):
def visit_Chunk(self, node):
new_body = Block([LocalAssign(
[Name("y")],
[Number(99)]
)])
return Chunk(new_body)

result = RootReplacer().visit(tree)
output = ast.to_lua_source(result)
self.assertIn("y = 99", output)
self.assertNotIn("x = 1", output)

def test_identity_transformer_roundtrip(self):
"""Full round-trip: parse -> transform (identity) -> generate Lua."""
src = textwrap.dedent("""\
function foo(a, b)
if a > b then
return a + 1
else
return b * 2
end
end
foo(10, 20)
""")
tree = ast.parse(src)
original_output = ast.to_lua_source(tree)

class Identity(ast.ASTTransformer):
pass

result = Identity().visit(tree)
result_output = ast.to_lua_source(result)

self.assertEqual(original_output, result_output)
self.assertIs(result, tree)

def test_visit_method_for_parent_class(self):
"""visit_Name is called for Name nodes; no crash on unrecognized types."""
src = "local x = 1"
tree = ast.parse(src)

visited_names = []

class NameCollector(ast.ASTTransformer):
def visit_Name(self, node):
visited_names.append(node.id)
return node

NameCollector().visit(tree)
self.assertIn("x", visited_names)

def test_replace_deeply_nested(self):
"""Replace a node deep in the tree."""
src = textwrap.dedent("""\
local t = {
a = {
b = 42
}
}
""")
tree = ast.parse(src)

class DeepReplacer(ast.ASTTransformer):
def visit_Number(self, node):
if node.n == 42:
return Number(999)
return node

result = DeepReplacer().visit(tree)
output = ast.to_lua_source(result)
self.assertIn("999", output)
self.assertNotIn("42", output)


Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat: add ASTTransformer for in-place AST node replacement by boolangery · Pull Request #80 · boolangery/py-lua-parser · GitHub
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
76 changes: 76 additions & 0 deletions luaparser/ast.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -98,6 +98,82 @@ def to_pretty_json(root: Node) -> str:
return json.dumps(root, cls=JSONEncoder, indent=4)



class ASTTransformer:
"""An iterative depth-first AST visitor that supports in-place node replacement.

Subclass and override visit_<NodeType>(node) methods. Each method receives
the current node and may return:

- A new :class:`~luaparser.astnodes.Node` instance to replace the current node
- ``None`` or the same node to leave it unchanged

The transformer propagates replacements up to the parent (or returns a new
root from :meth:`visit`). After a replacement, children of the *new* node
are visited -- not the original.

Example::

class NumberDoubler(ASTTransformer):
def visit_Number(self, node):
return Number(node.value * 2)

tree = ast.parse("x = 5")
new_tree = NumberDoubler().visit(tree)
"""

def visit(self, root):
"""Transform *root* and return the (possibly new) root node.

Returns None if *root* is None.
"""
if root is None:
return None

# Each stack entry is (node, parent_info) where parent_info is either
# None (for root) or (key, container) -- container is a Node or list.
node_stack = [(root, None)]

while node_stack:
node, parent_info = node_stack.pop()

if isinstance(node, Node):
# --- call visitor ---
name = "visit_" + node.__class__.__name__
visitor_method = getattr(self, name, None)
if visitor_method is not None:
replacement = visitor_method(node)
if replacement is not None and replacement is not node:
# Replace node in parent (or update root)
if parent_info is not None:
parent_key, parent_container = parent_info
if isinstance(parent_container, list):
parent_container[parent_key] = replacement
else:
setattr(parent_container, parent_key, replacement)
else:
root = replacement
node = replacement # visit replacement's children

# --- push children (reverse order for correct DFS) ---
children = [
attr for attr in node.__dict__.keys()
if not attr.startswith("_")
]
for child_key in reversed(children):
child = node.__dict__[child_key]
if isinstance(child, list):
for i in reversed(range(len(child))):
node_stack.append((child[i], (i, child)))
elif isinstance(child, Node):
node_stack.append((child, (child_key, node)))

elif isinstance(node, list):
for n in reversed(node):
node_stack.append((n, parent_info))

return root

class ASTVisitor:
def visit(self, root):
# base case:
Expand Down
248 changes: 248 additions & 0 deletions luaparser/tests/test_ast.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -158,3 +158,251 @@ def test_to_pretty_json(self):
}"""
)
self.assertEqual(ast.to_pretty_json(ast.parse(src)), exp)


class ASTTransformerTestCase(tests.TestCase):
"""Tests for ASTTransformer."""

def test_noop_returns_same_tree(self):
"""Transformer with no overrides returns identical structure."""
src = "local x = 1"
tree = ast.parse(src)

class NoopTransformer(ast.ASTTransformer):
pass

result = NoopTransformer().visit(tree)
self.assertEqual(ast.to_pretty_str(result), ast.to_pretty_str(tree))
# Root should be the same object when no transforms applied
self.assertIs(result, tree)

def test_replace_number(self):
"""Replace a Number node with a different value."""
src = "local x = 42"
tree = ast.parse(src)

class Doubler(ast.ASTTransformer):
def visit_Number(self, node):
return Number(node.n * 2)

result = Doubler().visit(tree)
self.assertIn("84", ast.to_pretty_str(result))
self.assertNotIn("42", ast.to_pretty_str(result))

def test_replace_string(self):
"""Replace a String literal."""
src = 'local msg = "hello"'
tree = ast.parse(src)

class StringUpper(ast.ASTTransformer):
def visit_String(self, node):
upper_raw = node.raw.upper()
return String(upper_raw.encode(), upper_raw, node.delimiter)

result = StringUpper().visit(tree)
output = ast.to_pretty_str(result)
self.assertIn("HELLO", output)
self.assertNotIn("hello", output)

def test_keep_node_by_returning_none(self):
"""Returning None keeps the original node."""
src = "local x = 42"
tree = ast.parse(src)

class KeepNumbers(ast.ASTTransformer):
def visit_Number(self, node):
if node.n == 42:
return None # keep
return Number(0)

result = KeepNumbers().visit(tree)
self.assertEqual(ast.to_pretty_str(result), ast.to_pretty_str(tree))

def test_keep_node_by_returning_same(self):
"""Returning the same node object keeps it."""
src = "local x = 42"
tree = ast.parse(src)

class IdentityTransformer(ast.ASTTransformer):
def visit_Number(self, node):
return node # same object

result = IdentityTransformer().visit(tree)
self.assertEqual(ast.to_pretty_str(result), ast.to_pretty_str(tree))
self.assertIs(result, tree)

def test_replace_statement(self):
"""Replace an entire statement node."""
src = "local x = 1"
tree = ast.parse(src)

class AssignToNil(ast.ASTTransformer):
def visit_LocalAssign(self, node):
# Replace local assignment with a nil assignment
return Assign(node.targets, [Nil()])

result = AssignToNil().visit(tree)
output = ast.to_lua_source(result)
self.assertIn("nil", output)
self.assertNotIn("local", output)

def test_replace_multiple_in_list(self):
"""Replace multiple nodes in a list (block body)."""
src = textwrap.dedent("""\
local a = 1
local b = 2
local c = 3
""")
tree = ast.parse(src)

class DropMiddleStatement(ast.ASTTransformer):
def visit_LocalAssign(self, node):
target_name = node.targets[0].id
if target_name == "b":
return None # remove it -- handled via parent removal
return node

result = DropMiddleStatement().visit(tree)
output = ast.to_lua_source(result)
self.assertIn("local a", output)
self.assertIn("local c", output)
# 'local b' should still be there since returning None keeps the node
self.assertIn("local b", output)

def test_replace_in_call_args(self):
"""Replace an argument inside a function call."""
src = "print(42)"
tree = ast.parse(src)

class ArgReplacer(ast.ASTTransformer):
def visit_Number(self, node):
s = str(node.n).encode()
return String(s, str(node.n), StringDelimiter.DOUBLE_QUOTE)

result = ArgReplacer().visit(tree)
output = ast.to_pretty_str(result)
self.assertIn("String", output)

def test_recursive_replacement(self):
"""Perform recursive transformations (expression folding)."""
src = "local x = 2 + 3"
tree = ast.parse(src)

class ConstantFolder(ast.ASTTransformer):
def visit_AddOp(self, node):
if isinstance(node.left, Number) and isinstance(node.right, Number):
return Number(node.left.n + node.right.n)
return node

result = ConstantFolder().visit(tree)
output = ast.to_lua_source(result)
self.assertIn("= 5", output)

def test_chained_replacement(self):
"""Replace a node, then replace a child of the replacement."""
src = textwrap.dedent("""\
if true then
x = 1
end
""")
tree = ast.parse(src)

class IfRewriter(ast.ASTTransformer):
def visit_If(self, node):
# Replace all if-statements with a do-block (for testing)
return Do(node.body)
def visit_TrueExpr(self, node):
# Replace true with false (tests ordering: If replaced first,
# so TrueExpr inside old If condition is never visited)
return FalseExpr()

result = IfRewriter().visit(tree)
output = ast.to_lua_source(result)
self.assertIn("do", output.lower())

def test_none_root_returns_none(self):
"""Passing None returns None."""
class NullTransformer(ast.ASTTransformer):
pass
self.assertIsNone(NullTransformer().visit(None))

def test_root_replacement(self):
"""Replace the root Chunk node."""
src = "local x = 1"
tree = ast.parse(src)

class RootReplacer(ast.ASTTransformer):
def visit_Chunk(self, node):
new_body = Block([LocalAssign(
[Name("y")],
[Number(99)]
)])
return Chunk(new_body)

result = RootReplacer().visit(tree)
output = ast.to_lua_source(result)
self.assertIn("y = 99", output)
self.assertNotIn("x = 1", output)

def test_identity_transformer_roundtrip(self):
"""Full round-trip: parse -> transform (identity) -> generate Lua."""
src = textwrap.dedent("""\
function foo(a, b)
if a > b then
return a + 1
else
return b * 2
end
end
foo(10, 20)
""")
tree = ast.parse(src)
original_output = ast.to_lua_source(tree)

class Identity(ast.ASTTransformer):
pass

result = Identity().visit(tree)
result_output = ast.to_lua_source(result)

self.assertEqual(original_output, result_output)
self.assertIs(result, tree)

def test_visit_method_for_parent_class(self):
"""visit_Name is called for Name nodes; no crash on unrecognized types."""
src = "local x = 1"
tree = ast.parse(src)

visited_names = []

class NameCollector(ast.ASTTransformer):
def visit_Name(self, node):
visited_names.append(node.id)
return node

NameCollector().visit(tree)
self.assertIn("x", visited_names)

def test_replace_deeply_nested(self):
"""Replace a node deep in the tree."""
src = textwrap.dedent("""\
local t = {
a = {
b = 42
}
}
""")
tree = ast.parse(src)

class DeepReplacer(ast.ASTTransformer):
def visit_Number(self, node):
if node.n == 42:
return Number(999)
return node

result = DeepReplacer().visit(tree)
output = ast.to_lua_source(result)
self.assertIn("999", output)
self.assertNotIn("42", output)


Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); feat: add ASTTransformer for in-place AST node replacement by boolangery · Pull Request #80 · boolangery/py-lua-parser · GitHub
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
76 changes: 76 additions & 0 deletions luaparser/ast.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -98,6 +98,82 @@ def to_pretty_json(root: Node) -> str:
return json.dumps(root, cls=JSONEncoder, indent=4)



class ASTTransformer:
"""An iterative depth-first AST visitor that supports in-place node replacement.

Subclass and override visit_<NodeType>(node) methods. Each method receives
the current node and may return:

- A new :class:`~luaparser.astnodes.Node` instance to replace the current node
- ``None`` or the same node to leave it unchanged

The transformer propagates replacements up to the parent (or returns a new
root from :meth:`visit`). After a replacement, children of the *new* node
are visited -- not the original.

Example::

class NumberDoubler(ASTTransformer):
def visit_Number(self, node):
return Number(node.value * 2)

tree = ast.parse("x = 5")
new_tree = NumberDoubler().visit(tree)
"""

def visit(self, root):
"""Transform *root* and return the (possibly new) root node.

Returns None if *root* is None.
"""
if root is None:
return None

# Each stack entry is (node, parent_info) where parent_info is either
# None (for root) or (key, container) -- container is a Node or list.
node_stack = [(root, None)]

while node_stack:
node, parent_info = node_stack.pop()

if isinstance(node, Node):
# --- call visitor ---
name = "visit_" + node.__class__.__name__
visitor_method = getattr(self, name, None)
if visitor_method is not None:
replacement = visitor_method(node)
if replacement is not None and replacement is not node:
# Replace node in parent (or update root)
if parent_info is not None:
parent_key, parent_container = parent_info
if isinstance(parent_container, list):
parent_container[parent_key] = replacement
else:
setattr(parent_container, parent_key, replacement)
else:
root = replacement
node = replacement # visit replacement's children

# --- push children (reverse order for correct DFS) ---
children = [
attr for attr in node.__dict__.keys()
if not attr.startswith("_")
]
for child_key in reversed(children):
child = node.__dict__[child_key]
if isinstance(child, list):
for i in reversed(range(len(child))):
node_stack.append((child[i], (i, child)))
elif isinstance(child, Node):
node_stack.append((child, (child_key, node)))

elif isinstance(node, list):
for n in reversed(node):
node_stack.append((n, parent_info))

return root

class ASTVisitor:
def visit(self, root):
# base case:
Expand Down
248 changes: 248 additions & 0 deletions luaparser/tests/test_ast.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -158,3 +158,251 @@ def test_to_pretty_json(self):
}"""
)
self.assertEqual(ast.to_pretty_json(ast.parse(src)), exp)


class ASTTransformerTestCase(tests.TestCase):
"""Tests for ASTTransformer."""

def test_noop_returns_same_tree(self):
"""Transformer with no overrides returns identical structure."""
src = "local x = 1"
tree = ast.parse(src)

class NoopTransformer(ast.ASTTransformer):
pass

result = NoopTransformer().visit(tree)
self.assertEqual(ast.to_pretty_str(result), ast.to_pretty_str(tree))
# Root should be the same object when no transforms applied
self.assertIs(result, tree)

def test_replace_number(self):
"""Replace a Number node with a different value."""
src = "local x = 42"
tree = ast.parse(src)

class Doubler(ast.ASTTransformer):
def visit_Number(self, node):
return Number(node.n * 2)

result = Doubler().visit(tree)
self.assertIn("84", ast.to_pretty_str(result))
self.assertNotIn("42", ast.to_pretty_str(result))

def test_replace_string(self):
"""Replace a String literal."""
src = 'local msg = "hello"'
tree = ast.parse(src)

class StringUpper(ast.ASTTransformer):
def visit_String(self, node):
upper_raw = node.raw.upper()
return String(upper_raw.encode(), upper_raw, node.delimiter)

result = StringUpper().visit(tree)
output = ast.to_pretty_str(result)
self.assertIn("HELLO", output)
self.assertNotIn("hello", output)

def test_keep_node_by_returning_none(self):
"""Returning None keeps the original node."""
src = "local x = 42"
tree = ast.parse(src)

class KeepNumbers(ast.ASTTransformer):
def visit_Number(self, node):
if node.n == 42:
return None # keep
return Number(0)

result = KeepNumbers().visit(tree)
self.assertEqual(ast.to_pretty_str(result), ast.to_pretty_str(tree))

def test_keep_node_by_returning_same(self):
"""Returning the same node object keeps it."""
src = "local x = 42"
tree = ast.parse(src)

class IdentityTransformer(ast.ASTTransformer):
def visit_Number(self, node):
return node # same object

result = IdentityTransformer().visit(tree)
self.assertEqual(ast.to_pretty_str(result), ast.to_pretty_str(tree))
self.assertIs(result, tree)

def test_replace_statement(self):
"""Replace an entire statement node."""
src = "local x = 1"
tree = ast.parse(src)

class AssignToNil(ast.ASTTransformer):
def visit_LocalAssign(self, node):
# Replace local assignment with a nil assignment
return Assign(node.targets, [Nil()])

result = AssignToNil().visit(tree)
output = ast.to_lua_source(result)
self.assertIn("nil", output)
self.assertNotIn("local", output)

def test_replace_multiple_in_list(self):
"""Replace multiple nodes in a list (block body)."""
src = textwrap.dedent("""\
local a = 1
local b = 2
local c = 3
""")
tree = ast.parse(src)

class DropMiddleStatement(ast.ASTTransformer):
def visit_LocalAssign(self, node):
target_name = node.targets[0].id
if target_name == "b":
return None # remove it -- handled via parent removal
return node

result = DropMiddleStatement().visit(tree)
output = ast.to_lua_source(result)
self.assertIn("local a", output)
self.assertIn("local c", output)
# 'local b' should still be there since returning None keeps the node
self.assertIn("local b", output)

def test_replace_in_call_args(self):
"""Replace an argument inside a function call."""
src = "print(42)"
tree = ast.parse(src)

class ArgReplacer(ast.ASTTransformer):
def visit_Number(self, node):
s = str(node.n).encode()
return String(s, str(node.n), StringDelimiter.DOUBLE_QUOTE)

result = ArgReplacer().visit(tree)
output = ast.to_pretty_str(result)
self.assertIn("String", output)

def test_recursive_replacement(self):
"""Perform recursive transformations (expression folding)."""
src = "local x = 2 + 3"
tree = ast.parse(src)

class ConstantFolder(ast.ASTTransformer):
def visit_AddOp(self, node):
if isinstance(node.left, Number) and isinstance(node.right, Number):
return Number(node.left.n + node.right.n)
return node

result = ConstantFolder().visit(tree)
output = ast.to_lua_source(result)
self.assertIn("= 5", output)

def test_chained_replacement(self):
"""Replace a node, then replace a child of the replacement."""
src = textwrap.dedent("""\
if true then
x = 1
end
""")
tree = ast.parse(src)

class IfRewriter(ast.ASTTransformer):
def visit_If(self, node):
# Replace all if-statements with a do-block (for testing)
return Do(node.body)
def visit_TrueExpr(self, node):
# Replace true with false (tests ordering: If replaced first,
# so TrueExpr inside old If condition is never visited)
return FalseExpr()

result = IfRewriter().visit(tree)
output = ast.to_lua_source(result)
self.assertIn("do", output.lower())

def test_none_root_returns_none(self):
"""Passing None returns None."""
class NullTransformer(ast.ASTTransformer):
pass
self.assertIsNone(NullTransformer().visit(None))

def test_root_replacement(self):
"""Replace the root Chunk node."""
src = "local x = 1"
tree = ast.parse(src)

class RootReplacer(ast.ASTTransformer):
def visit_Chunk(self, node):
new_body = Block([LocalAssign(
[Name("y")],
[Number(99)]
)])
return Chunk(new_body)

result = RootReplacer().visit(tree)
output = ast.to_lua_source(result)
self.assertIn("y = 99", output)
self.assertNotIn("x = 1", output)

def test_identity_transformer_roundtrip(self):
"""Full round-trip: parse -> transform (identity) -> generate Lua."""
src = textwrap.dedent("""\
function foo(a, b)
if a > b then
return a + 1
else
return b * 2
end
end
foo(10, 20)
""")
tree = ast.parse(src)
original_output = ast.to_lua_source(tree)

class Identity(ast.ASTTransformer):
pass

result = Identity().visit(tree)
result_output = ast.to_lua_source(result)

self.assertEqual(original_output, result_output)
self.assertIs(result, tree)

def test_visit_method_for_parent_class(self):
"""visit_Name is called for Name nodes; no crash on unrecognized types."""
src = "local x = 1"
tree = ast.parse(src)

visited_names = []

class NameCollector(ast.ASTTransformer):
def visit_Name(self, node):
visited_names.append(node.id)
return node

NameCollector().visit(tree)
self.assertIn("x", visited_names)

def test_replace_deeply_nested(self):
"""Replace a node deep in the tree."""
src = textwrap.dedent("""\
local t = {
a = {
b = 42
}
}
""")
tree = ast.parse(src)

class DeepReplacer(ast.ASTTransformer):
def visit_Number(self, node):
if node.n == 42:
return Number(999)
return node

result = DeepReplacer().visit(tree)
output = ast.to_lua_source(result)
self.assertIn("999", output)
self.assertNotIn("42", output)


Loading