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
17 changes: 12 additions & 5 deletions build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import java.nio.file.Files
import java.nio.file.Paths

plugins {
kotlin("jvm") version "2.1.0"
antlr
Expand Down Expand Up @@ -69,18 +72,22 @@ application {
// Dry-run by default; pass -PapproveSnapshotsCommit=true to actually git-mv and commit.
tasks.register<Exec>("approveSnapshots") {
group = "verification"
description = "Run scripts/approve_snapshots.sh (dry-run). Use -PapproveSnapshotsCommit=true to commit approvals."
description = "Run scripts/approve_snapshots.sh (dry-run). Use -PtestPath=/path/to/test to commit approvals."

val commit = (project.findProperty("approveSnapshotsCommit") as String?).toBoolean()
val testPath = project.findProperty("testPath") as String?
val scriptFile = file("scripts/approve_snapshots.sh")
if (!scriptFile.exists()) {
throw GradleException("Snapshot approval script not found: ${scriptFile.absolutePath}")
}

// Use bash for portability and to support script features.
commandLine = listOf("bash", scriptFile.absolutePath) + if (commit) listOf("--commit") else emptyList()
if (testPath == null) {
throw GradleException("Test path not found}")
}

// Stream output to console so user sees what will happen.
if (Files.notExists(Paths.get(testPath))) {
throw GradleException("Directory does not exist: $testPath")
}
commandLine = listOf("bash", scriptFile.absolutePath, testPath)
isIgnoreExitValue = false
}

Expand Down
95 changes: 26 additions & 69 deletions scripts/approve_snapshots.sh
Original file line number Diff line number Diff line change
@@ -1,83 +1,40 @@
#!/usr/bin/env bash
set -euo pipefail

# approve_snapshots.sh
# Find files named *.received.* (ApprovalTests pattern) and optionally rename them to *.approved.*
# Usage:
# ./scripts/approve_snapshots.sh # dry run - prints changes that would happen
# ./scripts/approve_snapshots.sh --commit # perform git mv and commit the changes
# ./scripts/approve_snapshots.sh --help # show help
# Usage: `scripts/approve_snapshots.sh` <directory>
# Rename all files in the given directory from '*.received.txt' to '*.approved.txt'.
# Non-recursive. Skips targets that already exist.

SHOW_HELP=0
DO_COMMIT=0

for arg in "$@"; do
case "$arg" in
--commit) DO_COMMIT=1 ;;
--help|-h) SHOW_HELP=1 ;;
*) echo "Unknown arg: $arg" ; exit 1 ;;
esac
done

if [ "$SHOW_HELP" -eq 1 ]; then
sed -n '1,200p' "$0"
exit 0
if [ "$#" -ne 1 ]; then
echo "Usage: $0 <directory>" >&2
exit 2
fi

# Find received files: patterns like *.received.txt or *.received.md or *.received.json
RECEIVED_FILES=()
while IFS= read -r -d '' file; do
RECEIVED_FILES+=("$file")
done < <(find . -type f -name "*.received.*" -print0)
dir="$1"

if [ ${#RECEIVED_FILES[@]} -eq 0 ]; then
echo "No received snapshot files found. Nothing to do."
exit 0
if [ ! -d "$dir" ]; then
echo "Error: '$dir' is not a directory" >&2
exit 3
fi

echo "Found ${#RECEIVED_FILES[@]} received file(s):"
for f in "${RECEIVED_FILES[@]}"; do
echo " $f"
done

actions=()
for f in "${RECEIVED_FILES[@]}"; do
# compute approved filename: replace .received. with .approved.
approved="${f//.received./.approved.}"
actions+=("$f -> $approved")
done
found_any=false

echo
if [ "$DO_COMMIT" -eq 0 ]; then
echo "DRY RUN: the following rename operations would be performed:"
for a in "${actions[@]}"; do
echo " $a"
done
echo
echo "Run with --commit to actually perform git mv and commit the changes."
exit 0
fi
for f in "$dir"/*.received.txt; do
if [ ! -e "$f" ]; then
# No matching files (shell left the pattern unexpanded)
if [ "$found_any" = false ]; then
echo "No '*.received.txt' files found in '$dir'." >&2
exit 0
fi
break
fi

# If we get here, perform commit
# Ensure working tree is clean
if [ -n "$(git status --porcelain)" ]; then
echo "Working tree is not clean. Please commit or stash your changes before running with --commit." >&2
git status --porcelain
exit 1
fi
found_any=true
[ -f "$f" ] || continue

# Perform git mv operations
for f in "${RECEIVED_FILES[@]}"; do
approved="${f//.received./.approved.}"
echo "git mv '$f' '$approved'"
git mv -- "$f" "$approved"
target="${f%.received.txt}.approved.txt"
mv "$f" "$target"
echo "Renamed: '$f' -> '$target'"
done

# Commit
msg="chore(test): approve snapshots ($(date -u +%Y-%m-%dT%H:%M:%SZ))"
git add -A
git commit -m "$msg"

echo "Committed snapshot approvals."

exit 0
exit 0
16 changes: 11 additions & 5 deletions src/main/kotlin/slang/hlir/Ast.kt
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
package slang.hlir

import SlangLexer
import SlangParser
import org.antlr.v4.runtime.ANTLRInputStream
import org.antlr.v4.runtime.CommonTokenStream
import slang.common.CodeInfo
import slang.common.CodeInfo.Companion.generic

Expand Down Expand Up @@ -66,7 +62,12 @@ enum class Operator {
}

data class ProgramUnit(
val stmt: List<Stmt>,
val stmt: List<SlangModule>,
) : SlastNode()

data class SlangModule(
val functions: List<Stmt.Function>,
val inlinedFuncs: List<Expr.InlinedFunction>,
) : SlastNode()

sealed class Stmt : SlastNode() {
Expand Down Expand Up @@ -257,6 +258,11 @@ fun SlastNode.prettyPrint(tabStop: Int = 0): String {
is Expr.ArrayInit -> "$indent [${elements.joinToString(", ") { it.prettyPrint() }}]"
is Stmt.Break -> "$indent break;"
is Stmt.Continue -> "$indent continue;"
is SlangModule -> {
val funcsStr = functions.joinToString("\n") { it.prettyPrint(tabStop) }
val inlinedStr = inlinedFuncs.joinToString("\n") { it.prettyPrint(tabStop) }
listOf(funcsStr, inlinedStr).filter { it.isNotBlank() }.joinToString("\n\n")
}
}
}

Expand Down
19 changes: 18 additions & 1 deletion src/main/kotlin/slang/hlir/AstBuilder.kt
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,24 @@ class SlastBuilder(
ProgramUnit(emptyList())
} else {
val stmts = ctx.stmt().map { visit(it) as Stmt }
ProgramUnit(stmts)
val moduleMain =
Stmt.Function(
"__module__main__",
emptyList(),
Stmt.BlockStmt(
stmts.filterNot {
it is Stmt.Function || (it is Stmt.ExprStmt && it.expr is Expr.InlinedFunction)
},
),
)
val topLevelInlineFuncs =
stmts
.filterIsInstance<Stmt.ExprStmt>()
.map { it.expr }
.filterIsInstance<Expr.InlinedFunction>()
val topLevelfuncs = stmts.filterIsInstance<Stmt.Function>()
val slangModule = SlangModule(listOf(moduleMain) + topLevelfuncs, topLevelInlineFuncs)
ProgramUnit(listOf(slangModule))
}
expr.codeInfo = createSourceCodeInfo(ctx)
return expr
Expand Down
73 changes: 61 additions & 12 deletions src/main/kotlin/slang/hlir/ControlFlowGraph.kt
Original file line number Diff line number Diff line change
Expand Up @@ -98,17 +98,34 @@ class CFGBuilder {
* Build CFG for a program
*/
fun buildForProgram(program: ProgramUnit): ControlFlowGraph {
// For backward compatibility with tests, return a single CFG representing the
// module-level "__module__main__" function if present. Otherwise, build a
// synthetic entry/exit with any top-level statements.
blockIdCounter = 0
allBlocks.clear()

val entry = newBlock()
val exit = newBlock()

val bodyBlock = buildForStmtList(program.stmt, exit)
addEdge(entry, bodyBlock.entry)
addEdge(bodyBlock.exit, exit)
if (program.stmt.isEmpty()) {
val entry = newBlock()
val exit = newBlock()
return ControlFlowGraph(entry, exit, allBlocks)
}

return ControlFlowGraph(entry, exit, allBlocks)
// Use the first module for program-level CFG
val module = program.stmt[0]

// Try to find the synthetic module main function created by the IR builder
val moduleMain = module.functions.find { it.name == "__module__main__" }
return if (moduleMain != null) {
buildForFunction(moduleMain)
} else if (module.functions.isNotEmpty()) {
// Fallback: build CFG for the first top-level function
buildForFunction(module.functions[0])
} else {
// No functions: create empty entry/exit
val entry = newBlock()
val exit = newBlock()
ControlFlowGraph(entry, exit, allBlocks)
}
}

private data class CFGSegment(
Expand All @@ -130,13 +147,25 @@ class CFGBuilder {
var currentSegment = buildForStmt(stmts[0], exitBlock)
val entry = currentSegment.entry

val accumulatedBreaks = mutableListOf<BasicBlock>()
val accumulatedContinues = mutableListOf<BasicBlock>()
accumulatedBreaks.addAll(currentSegment.breakTargets)
accumulatedContinues.addAll(currentSegment.continueTargets)

for (i in 1 until stmts.size) {
val nextSegment = buildForStmt(stmts[i], exitBlock)
// Normal flow: connect the previous segment's exit to the next segment's entry
addEdge(currentSegment.exit, nextSegment.entry)
currentSegment = CFGSegment(entry, nextSegment.exit)

// Accumulate break/continue targets from subsequent segments; they should not be
// connected into the normal fall-through chain here (they are handled by loops)
accumulatedBreaks.addAll(nextSegment.breakTargets)
accumulatedContinues.addAll(nextSegment.continueTargets)

currentSegment = CFGSegment(entry, nextSegment.exit, accumulatedBreaks.toList(), accumulatedContinues.toList())
}

return CFGSegment(entry, currentSegment.exit)
return CFGSegment(entry, currentSegment.exit, accumulatedBreaks.toList(), accumulatedContinues.toList())
}

private fun buildForStmt(
Expand Down Expand Up @@ -175,32 +204,52 @@ class CFGBuilder {
addEdge(thenSegment.exit, mergeBlock)
addEdge(elseSegment.exit, mergeBlock)

CFGSegment(condBlock, mergeBlock)
// combine break/continue targets from both branches and propagate upward
val combinedBreaks = thenSegment.breakTargets + elseSegment.breakTargets
val combinedContinues = thenSegment.continueTargets + elseSegment.continueTargets

CFGSegment(condBlock, mergeBlock, combinedBreaks, combinedContinues)
}

is Stmt.WhileStmt -> {
val condBlock = newBlock(listOf(stmt))
val mergeBlock = newBlock()

// Build the loop body with the knowledge that its breaks should target mergeBlock
val bodySegment = buildForStmt(stmt.body, exitBlock)

// Normal loop edges: cond -> body, body -> cond, cond -> merge (loop exit)
addEdge(condBlock, bodySegment.entry)
addEdge(bodySegment.exit, condBlock)
addEdge(condBlock, mergeBlock)

// Resolve break targets inside the loop: they should jump to mergeBlock
for (bt in bodySegment.breakTargets) {
addEdge(bt, mergeBlock)
}

// Resolve continue targets inside the loop: they should jump to the condition block
for (ct in bodySegment.continueTargets) {
addEdge(ct, condBlock)
}

// Consumed break/continue targets should not propagate beyond this loop
CFGSegment(condBlock, mergeBlock)
}

is Stmt.Break -> {
val block = newBlock(listOf(stmt))
// Break statements need special handling - they jump to the loop exit
// For now, we create a dead-end block
// Represent a break by returning the block as a break target. It will be
// wired up by the nearest enclosing loop to jump to the loop exit.
CFGSegment(block, block, breakTargets = listOf(block))
}

is Stmt.Continue -> {
val block = newBlock(listOf(stmt))
// Continue statements need special handling - they jump to the loop condition
// For now, we create a dead-end block
// Represent a continue by returning the block as a continue target. It will be
// wired up by the nearest enclosing loop to jump back to the loop condition.
CFGSegment(block, block, continueTargets = listOf(block))
}

Expand Down
26 changes: 23 additions & 3 deletions src/main/kotlin/slang/runtime/Interpreter.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,29 @@ class Interpreter {
program: ProgramUnit,
state: ConcreteState = ConcreteState(),
): ConcreteState =
program.stmt.fold(state) { currentState, stmt ->
val (newState, _) = executeStmt(stmt, currentState)
newState
// ProgramUnit now contains a list of SlangModule entries. Each module holds top-level functions
// and a synthetic module main that contains top-level statements. We need to:
// 1) register all top-level functions into the environment
// 2) execute the synthetic module main body so top-level stmts run
program.stmt.fold(state) { currentState, module ->
var s = currentState

// Register all top-level functions except the synthetic module main
val moduleMain = module.functions.find { it.name == "__module__main__" }
for (f in module.functions) {
if (f !== moduleMain) {
val (newState, _) = executeStmt(f, s)
s = newState
}
}

// Execute the synthetic module main body (if present) to run top-level statements
if (moduleMain != null) {
val (afterMainState, _) = executeStmt(moduleMain.body, s)
s = afterMainState
}

s
}

private fun executeStmt(
Expand Down
8 changes: 8 additions & 0 deletions src/main/kotlin/slang/ui/AstVisualizer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import org.fife.ui.rsyntaxtextarea.TokenMakerFactory
import org.fife.ui.rtextarea.RTextScrollPane
import slang.hlir.Expr
import slang.hlir.ProgramUnit
import slang.hlir.SlangModule
import slang.hlir.SlastNode
import slang.hlir.Stmt
import slang.hlir.prettyPrint
Expand Down Expand Up @@ -184,6 +185,13 @@ fun SlastNode.toTreeNode(): DefaultMutableTreeNode =
)
}
}
is SlangModule ->
DefaultMutableTreeNode("Module").apply {
add(DefaultMutableTreeNode("Functions").apply { functions.forEach { add(it.toTreeNode()) } })
if (inlinedFuncs.isNotEmpty()) {
add(DefaultMutableTreeNode("InlinedFunctions").apply { inlinedFuncs.forEach { add(it.toTreeNode()) } })
}
}
}

fun expandAllNodes(tree: JTree) {
Expand Down
4 changes: 2 additions & 2 deletions src/test/kotlin/ControlFlowGraphTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,8 @@ class ControlFlowGraphTest {
assertTrue(result is Result.Ok)
val programUnit = (result as Result.Ok).value

// Get the function
val function = programUnit.stmt.filterIsInstance<Stmt.Function>().firstOrNull()
// Get the user-defined function (skip synthetic module main)
val function = programUnit.stmt.flatMap { it.functions }.firstOrNull { it.name != "__module__main__" }
assertNotNull(function)

val cfg = function.buildCFG()
Expand Down
Loading