diff options
| author | Elizabeth Hunt <me@liz.coffee> | 2026-01-23 20:22:30 -0800 |
|---|---|---|
| committer | Elizabeth Hunt <me@liz.coffee> | 2026-01-23 20:22:30 -0800 |
| commit | 52864cb701e59a1d847fd5586245519eb5e3b3bc (patch) | |
| tree | 1d3df85b939e2c50ebf154ab4fcac6f02ad087c2 /core/src/main | |
| download | the-abstraction-engine-v2-52864cb701e59a1d847fd5586245519eb5e3b3bc.tar.gz the-abstraction-engine-v2-52864cb701e59a1d847fd5586245519eb5e3b3bc.zip | |
Move code over
Diffstat (limited to 'core/src/main')
48 files changed, 2935 insertions, 0 deletions
diff --git a/core/src/main/java/coffee/liz/abstractionengine/AbstractionEngineGridWorld.java b/core/src/main/java/coffee/liz/abstractionengine/AbstractionEngineGridWorld.java new file mode 100644 index 0000000..c6e7a49 --- /dev/null +++ b/core/src/main/java/coffee/liz/abstractionengine/AbstractionEngineGridWorld.java @@ -0,0 +1,36 @@ +package coffee.liz.abstractionengine; + +import coffee.liz.abstractionengine.grid.component.GridInputState; +import coffee.liz.abstractionengine.grid.system.GridCollisionPropagatationSystem; +import coffee.liz.abstractionengine.grid.system.GridIndexSystem; +import coffee.liz.abstractionengine.grid.system.GridMovementSystem; +import coffee.liz.abstractionengine.grid.system.GridPhysicsSystem; +import coffee.liz.ecs.DAGWorld; +import coffee.liz.ecs.math.Vec2; + +import lombok.extern.log4j.Log4j2; + +import java.time.Duration; +import java.util.Map; + +/** Grid world implementation for the abstraction engine. */ +@Log4j2 +public class AbstractionEngineGridWorld extends DAGWorld<GridInputState> { + /** Initialize world with systems and player. */ + public AbstractionEngineGridWorld(final Vec2<Integer> gridDimensions) { + super(Map.of(GridMovementSystem.class, new GridMovementSystem(), GridPhysicsSystem.class, + new GridPhysicsSystem(), GridIndexSystem.class, new GridIndexSystem(gridDimensions), + GridCollisionPropagatationSystem.class, new GridCollisionPropagatationSystem())); + } + + /** + * Update world with input state. For now our grid world is a "step function", + * so we shouldn't (yet) care about the amount of time between steps. + * + * @param state + * the {@link GridInputState} + */ + public void update(final GridInputState state) { + update(state, Duration.ZERO); + } +} diff --git a/core/src/main/java/coffee/liz/abstractionengine/app/AbstractionEngineGame.java b/core/src/main/java/coffee/liz/abstractionengine/app/AbstractionEngineGame.java new file mode 100644 index 0000000..a6cd6e9 --- /dev/null +++ b/core/src/main/java/coffee/liz/abstractionengine/app/AbstractionEngineGame.java @@ -0,0 +1,61 @@ +package coffee.liz.abstractionengine.app; + +import coffee.liz.abstractionengine.app.screen.Logo; +import coffee.liz.ecs.math.Vec2; +import coffee.liz.ecs.math.Vec2f; +import com.badlogic.gdx.Game; +import com.badlogic.gdx.Gdx; +import com.badlogic.gdx.graphics.Texture; +import com.badlogic.gdx.graphics.g2d.BitmapFont; +import com.badlogic.gdx.graphics.g2d.SpriteBatch; +import com.badlogic.gdx.graphics.g2d.freetype.FreeTypeFontGenerator; +import com.badlogic.gdx.graphics.glutils.ShapeRenderer; +import com.badlogic.gdx.utils.viewport.FitViewport; + +public class AbstractionEngineGame extends Game { + public static final Vec2<Float> WORLD_SIZE = Vec2f.builder().x(200f).y(200f).build(); + private static final String RENDERABLE_CHARS = "λ" + FreeTypeFontGenerator.DEFAULT_CHARS; + + public SpriteBatch batch; + public BitmapFont font; + public FitViewport viewport; + public ShapeRenderer shapeRenderer; + + public void create() { + viewport = new FitViewport(WORLD_SIZE.getX(), WORLD_SIZE.getY()); + batch = new SpriteBatch(); + shapeRenderer = new ShapeRenderer(); + font = initFont(24); + + this.setScreen(new Logo(this)); + + viewport.update(Gdx.graphics.getWidth(), Gdx.graphics.getHeight(), true); + } + + public void render() { + super.render(); + } + + public void dispose() { + batch.dispose(); + font.dispose(); + shapeRenderer.dispose(); + } + + private BitmapFont initFont(final int size) { + final FreeTypeFontGenerator gen = new FreeTypeFontGenerator( + Gdx.files.internal("fonts/JetBrainsMonoNerdFont-Regular.ttf")); + final FreeTypeFontGenerator.FreeTypeFontParameter params = new FreeTypeFontGenerator.FreeTypeFontParameter(); + params.characters = RENDERABLE_CHARS; + params.size = size; + + final BitmapFont font = gen.generateFont(params); + font.setFixedWidthGlyphs(RENDERABLE_CHARS); + font.setUseIntegerPositions(false); + font.getData().setScale(WORLD_SIZE.getY() / Gdx.graphics.getHeight()); + font.getRegion().getTexture().setFilter(Texture.TextureFilter.Linear, Texture.TextureFilter.Linear); + + gen.dispose(); + return font; + } +} diff --git a/core/src/main/java/coffee/liz/abstractionengine/app/life/CellState.java b/core/src/main/java/coffee/liz/abstractionengine/app/life/CellState.java new file mode 100644 index 0000000..94cfdd8 --- /dev/null +++ b/core/src/main/java/coffee/liz/abstractionengine/app/life/CellState.java @@ -0,0 +1,29 @@ +package coffee.liz.abstractionengine.app.life; + +import coffee.liz.ecs.model.Component; +import lombok.Getter; + +@Getter +public class CellState implements Component { + private static final float EPS = 1.0e-4f; + + public static final CellState LIVE = new CellState(1.0f); + public static final CellState DEAD = new CellState(0.0f); + + private final float decay; + + public CellState(final float decay) { + this.decay = clamp(decay); + } + + public boolean isAlive() { + return decay >= (1.0f - EPS); + } + + private static float clamp(final float value) { + if (value <= 0.0f) { + return 0.0f; + } + return Math.min(value, 1.0f); + } +} diff --git a/core/src/main/java/coffee/liz/abstractionengine/app/life/LifeInput.java b/core/src/main/java/coffee/liz/abstractionengine/app/life/LifeInput.java new file mode 100644 index 0000000..2213ecc --- /dev/null +++ b/core/src/main/java/coffee/liz/abstractionengine/app/life/LifeInput.java @@ -0,0 +1,13 @@ +package coffee.liz.abstractionengine.app.life; + +import coffee.liz.ecs.math.Vec2; +import lombok.Data; +import lombok.RequiredArgsConstructor; + +import java.util.Set; + +@RequiredArgsConstructor +@Data +public class LifeInput { + private final Set<Vec2<Integer>> forceAliveCells; +} diff --git a/core/src/main/java/coffee/liz/abstractionengine/app/life/LifeSystem.java b/core/src/main/java/coffee/liz/abstractionengine/app/life/LifeSystem.java new file mode 100644 index 0000000..803fe58 --- /dev/null +++ b/core/src/main/java/coffee/liz/abstractionengine/app/life/LifeSystem.java @@ -0,0 +1,71 @@ +package coffee.liz.abstractionengine.app.life; + +import coffee.liz.abstractionengine.grid.component.GridPosition; +import coffee.liz.abstractionengine.grid.system.BaseGridIndexSystem; +import coffee.liz.ecs.math.Mat2; +import coffee.liz.ecs.math.Vec2; +import coffee.liz.ecs.math.Vec2i; +import coffee.liz.ecs.model.Entity; +import coffee.liz.ecs.model.World; + +import java.time.Duration; +import java.util.Set; + +/** Conway's game of life */ +public class LifeSystem extends BaseGridIndexSystem<LifeInput> { + public final static int MAX_DECAY_STEPS = 12; + private final static float DECAY_STEP = 1.0f / MAX_DECAY_STEPS; + private final static Vec2<Integer> CONVOLUTION_RADIUS = Vec2i.builder().x(1).y(1).build(); + + private final static Duration BETWEEN_UPDATES = Duration.ofMillis(200); + + private Duration sinceUpdate = Duration.ZERO; + + public LifeSystem(final Vec2<Integer> dimensions) { + super(dimensions); + } + + @Override + public void update(final World<LifeInput> world, final LifeInput state, final Duration dt) { + super.update(world, state, dt); + state.getForceAliveCells().forEach(cell -> { + if (!inBounds(cell)) { + return; + } + final Set<Entity> entities = rows.get(cell.getY()).get(cell.getX()); + entities.forEach(e -> e.add(CellState.LIVE)); + }); + + sinceUpdate = sinceUpdate.plus(dt); + if (sinceUpdate.compareTo(BETWEEN_UPDATES) < 0) { + return; + } + sinceUpdate = Duration.ZERO; + + Mat2.convolve(this.rows, CONVOLUTION_RADIUS, () -> 0, (entities, rel, prev) -> { + if (rel.equals(Vec2i.ZERO)) { + return prev; + } + return entities.stream().findFirst().map(entity -> entity.get(CellState.class)) + .map(cellState -> prev + (cellState.isAlive() ? 1 : 0)).orElse(prev); + }, (entities, neighboringAliveCells) -> entities.stream().findFirst().map(entity -> { + final CellState cellState = entity.get(CellState.class); + final float decay = cellState.getDecay(); + + final boolean alive = cellState.isAlive(); + final boolean diesNow = alive && (neighboringAliveCells < 2 || neighboringAliveCells > 3); + final boolean spawnsNow = !alive && neighboringAliveCells == 3; + final boolean stillAlive = alive && !diesNow; + + final CellState nextState; + if (spawnsNow || stillAlive) { + nextState = CellState.LIVE; + } else { + nextState = new CellState(decay - DECAY_STEP); + } + + entity.add(nextState); + return entity; + })); + } +} diff --git a/core/src/main/java/coffee/liz/abstractionengine/app/screen/Logo.java b/core/src/main/java/coffee/liz/abstractionengine/app/screen/Logo.java new file mode 100644 index 0000000..82b637a --- /dev/null +++ b/core/src/main/java/coffee/liz/abstractionengine/app/screen/Logo.java @@ -0,0 +1,53 @@ +package coffee.liz.abstractionengine.app.screen; + +import coffee.liz.abstractionengine.app.AbstractionEngineGame; +import com.badlogic.gdx.Screen; +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; + +@RequiredArgsConstructor +@Log4j2 +public class Logo implements Screen { + private final AbstractionEngineGame game; + float secondsShown = 0; + + @Override + public void show() { + } + + @Override + public void render(final float delta) { + secondsShown += delta; + if (secondsShown < 2f) { + return; + } + + log.info("Transition to main menu after {}", secondsShown); + game.setScreen(new MainMenu(game)); + dispose(); + } + + @Override + public void resize(int width, int height) { + + } + + @Override + public void pause() { + + } + + @Override + public void resume() { + } + + @Override + public void hide() { + + } + + @Override + public void dispose() { + + } +} diff --git a/core/src/main/java/coffee/liz/abstractionengine/app/screen/MainMenu.java b/core/src/main/java/coffee/liz/abstractionengine/app/screen/MainMenu.java new file mode 100644 index 0000000..e02acf4 --- /dev/null +++ b/core/src/main/java/coffee/liz/abstractionengine/app/screen/MainMenu.java @@ -0,0 +1,156 @@ +package coffee.liz.abstractionengine.app.screen; + +import coffee.liz.abstractionengine.app.AbstractionEngineGame; +import coffee.liz.abstractionengine.app.life.CellState; +import coffee.liz.abstractionengine.app.life.LifeInput; +import coffee.liz.abstractionengine.app.life.LifeSystem; +import coffee.liz.abstractionengine.grid.component.GridPosition; +import coffee.liz.ecs.DAGWorld; +import coffee.liz.ecs.math.Mat2; +import coffee.liz.ecs.math.Vec2; +import coffee.liz.ecs.math.Vec2f; +import coffee.liz.ecs.math.Vec2i; +import coffee.liz.ecs.model.World; +import com.badlogic.gdx.Gdx; +import com.badlogic.gdx.Screen; +import com.badlogic.gdx.graphics.Color; +import com.badlogic.gdx.graphics.glutils.ShapeRenderer; +import com.badlogic.gdx.math.Vector3; +import com.badlogic.gdx.utils.ScreenUtils; +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; + +import java.time.Duration; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.stream.Stream; + +import static com.badlogic.gdx.math.MathUtils.clamp; + +@Log4j2 +@RequiredArgsConstructor +public class MainMenu implements Screen { + private static final Vec2<Integer> DIMENSIONS = Vec2i.builder().x(50).y(50).build(); + private final World<LifeInput> world = new DAGWorld<>(Map.of(LifeSystem.class, new LifeSystem(DIMENSIONS))); + private final Color cellColor = new Color(); + + private final AbstractionEngineGame game; + + @Override + public void show() { + final Set<Vec2<Integer>> glider = Set.of(Vec2i.builder().x(1).y(0).build(), Vec2i.builder().x(2).y(1).build(), + Vec2i.builder().x(0).y(2).build(), Vec2i.builder().x(1).y(2).build(), + Vec2i.builder().x(2).y(2).build()); + Mat2.init(DIMENSIONS, pos -> world.createEntity().add(new GridPosition(pos)) + .add(glider.contains(pos) ? CellState.LIVE : CellState.DEAD)); + } + + @Override + public void render(final float delta) { + game.viewport.apply(); + + final Vec2<Float> cellSize = Vec2f.builder().x(game.viewport.getWorldWidth() / DIMENSIONS.getX()) + .y(game.viewport.getWorldHeight() / DIMENSIONS.getY()).build(); + final Set<Vec2<Integer>> forcedAlive = computeForcedAlive(cellSize); + + world.update(new LifeInput(forcedAlive), Duration.ofMillis((int) (delta * 1000))); + ScreenUtils.clear(Color.CLEAR); + + drawCells(cellSize); + + // game.batch.setProjectionMatrix(game.viewport.getCamera().combined); + // + // for (int i = 0; i < 200; i++) { + // game.shapeRenderer.rect(i, i, 1, 1); + // } + // game.shapeRenderer.end(); + // + // + // game.batch.begin(); + // game.font.setColor(Color.WHITE); + // game.font.draw(game.batch, "Welcome to Drop!!! ", 1, 50); + // game.font.draw(game.batch, "λλλTap anywhere to begin!", 1, 100); + // game.batch.end(); + // + // // world.update(new LifeInput(List.of()), Duration.ofMillis((long)(delta * + // // 1_000))); + // // + // // world.query(Set.of(GridPosition.class, + // StepsSinceLive.class)).forEach(entity + // // -> { + // // final GridPosition gridPosition = entity.get(GridPosition.class); + // // final StepsSinceLive stepsSinceLive = entity.get(StepsSinceLive.class); + // // + // // + // // }); + } + + private Set<Vec2<Integer>> computeForcedAlive(final Vec2<Float> cellSize) { + final Vector3 cursorPosition = game.viewport.unproject(new Vector3(Gdx.input.getX(), Gdx.input.getY(), 0)); + final Vec2<Float> cursorWorld = Vec2f.builder().x(cursorPosition.x).y(cursorPosition.y).build(); + final float clampedX = clamp(cursorWorld.getX(), 0.0f, game.viewport.getWorldWidth() - 0.001f); + final float clampedY = clamp(cursorWorld.getY(), 0.0f, game.viewport.getWorldHeight() - 0.001f); + final Vec2<Integer> gridPosition = Vec2i.builder().x((int) (clampedX / cellSize.getX())) + .y((int) (clampedY / cellSize.getY())).build(); + final Set<Vec2<Integer>> forcedAlive = new HashSet<>(); + if (Gdx.input.isTouched()) { + Stream.of(gridPosition).flatMap( + pos -> Stream.of(Vec2i.ZERO, Vec2i.EAST, Vec2i.WEST, Vec2i.NORTH, Vec2i.SOUTH).map(pos::plus)) + .forEach(forcedAlive::add); + } + return forcedAlive; + } + + private void drawCells(final Vec2<Float> cellSize) { + game.shapeRenderer.begin(ShapeRenderer.ShapeType.Filled); + game.shapeRenderer.setProjectionMatrix(game.viewport.getCamera().combined); + world.query(Set.of(GridPosition.class, CellState.class)).forEach(entity -> { + final CellState state = entity.get(CellState.class); + final Vec2<Integer> gridPos = entity.get(GridPosition.class).getPosition(); + final Vec2<Float> worldPos = Vec2f.builder().x(gridPos.getX() * cellSize.getX()) + .y(gridPos.getY() * cellSize.getY()).build(); + + final float decay = state.getDecay(); + if (decay <= 0.0f) { + return; + } + game.shapeRenderer.setColor(interpolateColor(decay)); + game.shapeRenderer.rect(worldPos.getX(), worldPos.getY(), cellSize.getX(), cellSize.getY()); + }); + game.shapeRenderer.end(); + } + + private Color interpolateColor(final float decay) { + final float bright = decay * decay * decay; + final float r = 0.02f + (0.98f * bright); + final float g = 0.02f + (0.98f * bright); + final float b = 0.02f + (0.98f * bright); + return cellColor.set(r, g, b, 1.0f); + } + + @Override + public void resize(final int width, final int height) { + game.viewport.update(width, height, true); + } + + @Override + public void pause() { + + } + + @Override + public void resume() { + + } + + @Override + public void hide() { + + } + + @Override + public void dispose() { + world.query(Set.of()).forEach(world::removeEntity); + } +} diff --git a/core/src/main/java/coffee/liz/abstractionengine/entity/EntityType.java b/core/src/main/java/coffee/liz/abstractionengine/entity/EntityType.java new file mode 100644 index 0000000..e0bf243 --- /dev/null +++ b/core/src/main/java/coffee/liz/abstractionengine/entity/EntityType.java @@ -0,0 +1,52 @@ +package coffee.liz.abstractionengine.entity; + +import coffee.liz.abstractionengine.grid.component.GridCollidable.CollisionBehavior; +import coffee.liz.abstractionengine.grid.component.GridCollidable.CollisionBehavior.CollisionBehaviorType; +import coffee.liz.ecs.model.Component; + +/** + * Entity type in the + * {@link coffee.liz.abstractionengine.AbstractionEngineGridWorld} + */ +public enum EntityType implements Component { + /** Player entity */ + PLAYER, + /** Wall entity. Nothing goes through this */ + WALL, + /** Lambda term producer */ + PRODUCER, + /** Lambda abstraction */ + ABSTRACTION, + /** Lambda application */ + APPLICATION, + /** Lava */ + LAVA, + /** Bridge */ + BRIDGE; + + /** + * Gets the collision behavior to apply based on the colliding types. + * + * @param that + * is the {@link EntityType} colliding. + * @return collision behavior to resolve. + */ + public CollisionBehavior collideWith(final EntityType that) { + if (that.equals(WALL)) { + return CollisionBehavior.builder().collisionBehaviorType(CollisionBehaviorType.WALL).priority(0).build(); + } + if (this.equals(ABSTRACTION) && that.equals(APPLICATION)) { + return CollisionBehavior.builder().collisionBehaviorType(CollisionBehaviorType.SWALLOW).priority(0).build(); + } + + if (that.equals(LAVA)) { + return CollisionBehavior.builder().collisionBehaviorType(CollisionBehaviorType.SWALLOW).priority(1).build(); + } + if (that.equals(BRIDGE)) { // BRIDGE takes precedence over LAVA, so stuff can walk on a bridge over + // LAVA + return CollisionBehavior.builder().collisionBehaviorType(CollisionBehaviorType.SWALLOW).priority(0).build(); + } + + return CollisionBehavior.builder().collisionBehaviorType(CollisionBehaviorType.PROPAGATE).priority(0).build(); + } +} diff --git a/core/src/main/java/coffee/liz/abstractionengine/entity/EntityTypeGridCollidable.java b/core/src/main/java/coffee/liz/abstractionengine/entity/EntityTypeGridCollidable.java new file mode 100644 index 0000000..c75b717 --- /dev/null +++ b/core/src/main/java/coffee/liz/abstractionengine/entity/EntityTypeGridCollidable.java @@ -0,0 +1,14 @@ +package coffee.liz.abstractionengine.entity; + +import coffee.liz.abstractionengine.grid.component.GridCollidable; +import coffee.liz.ecs.model.Entity; + +import lombok.Data; + +@Data +public class EntityTypeGridCollidable implements GridCollidable { + @Override + public CollisionBehavior getCollisionBehaviorBetween(final Entity me, final Entity them) { + return me.get(EntityType.class).collideWith(them.get(EntityType.class)); + } +} diff --git a/core/src/main/java/coffee/liz/abstractionengine/entity/PlayerFactory.java b/core/src/main/java/coffee/liz/abstractionengine/entity/PlayerFactory.java new file mode 100644 index 0000000..240a94e --- /dev/null +++ b/core/src/main/java/coffee/liz/abstractionengine/entity/PlayerFactory.java @@ -0,0 +1,27 @@ +package coffee.liz.abstractionengine.entity; + +import coffee.liz.abstractionengine.grid.component.GridControllable; +import coffee.liz.abstractionengine.grid.component.GridPosition; +import coffee.liz.ecs.math.Vec2; +import coffee.liz.ecs.model.Entity; +import coffee.liz.ecs.model.World; + +/** Factory for creating player {@link Entity}s. */ +public final class PlayerFactory { + private PlayerFactory() { + } + + /** + * Create a player entity. + * + * @param world + * the {@link World} to create in + * @param position + * the starting {@link Vec2} position + * @return created player {@link Entity} + */ + public static Entity addToWorld(final World<?> world, final Vec2<Integer> position) { + return world.createEntity().add(new GridPosition(position)).add(new GridControllable()).add(EntityType.PLAYER) + .add(new EntityTypeGridCollidable()); + } +} diff --git a/core/src/main/java/coffee/liz/abstractionengine/grid/component/GridCollidable.java b/core/src/main/java/coffee/liz/abstractionengine/grid/component/GridCollidable.java new file mode 100644 index 0000000..e72d4e8 --- /dev/null +++ b/core/src/main/java/coffee/liz/abstractionengine/grid/component/GridCollidable.java @@ -0,0 +1,65 @@ +package coffee.liz.abstractionengine.grid.component; + +import coffee.liz.ecs.model.Component; +import coffee.liz.ecs.model.Entity; +import coffee.liz.ecs.model.World; + +import lombok.Builder; +import lombok.Data; +import lombok.RequiredArgsConstructor; + +/** {@link Component} for grid collision handling. */ +public interface GridCollidable extends Component { + @RequiredArgsConstructor + @Data + @Builder + class CollisionBehavior implements Comparable<CollisionBehavior> { + private final CollisionBehaviorType collisionBehaviorType; + private final int priority; + + public int compareTo(final CollisionBehavior other) { + return Integer.compare(getPriority(), other.getPriority()); + } + + public enum CollisionBehaviorType { + /** Propagate collision to next entity. */ + PROPAGATE, + /** Block collision like a wall. */ + WALL, + /** Swallow the colliding entity. */ + SWALLOW; + } + } + + /** + * Get collision behavior for colliding entity. + * + * @param me + * the staring colliding {@link Entity} + * @param them + * the colliding {@link Entity} + * @return the {@link CollisionBehavior} + */ + CollisionBehavior getCollisionBehaviorBetween(final Entity me, final Entity them); + + /** + * Handle swallowing an entity. + * + * @param them + * the {@link Entity} being swallowed + * @param world + * the {@link World} to modify + */ + default <T> void onSwallow(final Entity them, final World<T> world) { + throw new UnsupportedOperationException("Does not swallow"); // ...could not be me~ (つ﹏⊂) + } + + /** + * Anything that implements GridCollidable should be keyed by GridCollidable. + * + * @return Key type + */ + default Class<? extends Component> getKey() { + return GridCollidable.class; + } +} diff --git a/core/src/main/java/coffee/liz/abstractionengine/grid/component/GridControllable.java b/core/src/main/java/coffee/liz/abstractionengine/grid/component/GridControllable.java new file mode 100644 index 0000000..b4a9ba9 --- /dev/null +++ b/core/src/main/java/coffee/liz/abstractionengine/grid/component/GridControllable.java @@ -0,0 +1,10 @@ +package coffee.liz.abstractionengine.grid.component; + +import coffee.liz.ecs.model.Component; + +import lombok.Data; + +/** {@link Component} marking an entity as player-controllable. */ +@Data +public class GridControllable implements Component { +} diff --git a/core/src/main/java/coffee/liz/abstractionengine/grid/component/GridInputState.java b/core/src/main/java/coffee/liz/abstractionengine/grid/component/GridInputState.java new file mode 100644 index 0000000..8fadb09 --- /dev/null +++ b/core/src/main/java/coffee/liz/abstractionengine/grid/component/GridInputState.java @@ -0,0 +1,37 @@ +package coffee.liz.abstractionengine.grid.component; + +import coffee.liz.ecs.math.Vec2; +import coffee.liz.ecs.model.Entity; + +import coffee.liz.lambda.ast.LambdaProgram; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import jakarta.annotation.Nullable; + +/** {@link coffee.liz.ecs.model.Component} for player input state. */ +@Getter +@AllArgsConstructor +@Builder +public class GridInputState { + /** Movement {@link Vec2} direction. */ + @Nullable + private final Vec2<Integer> movement; + + /** Lambda term update to apply. */ + @Nullable + private final UpdateLambdaTerm updateLambdaTerm; + + /** Update lambda term with target entity. */ + @Getter + @RequiredArgsConstructor + public static class UpdateLambdaTerm { + /** {@link Entity} to update. */ + private final Entity toUpdate; + + /** {@link LambdaProgram} to apply. */ + private final LambdaProgram lambdaProgram; + } +} diff --git a/core/src/main/java/coffee/liz/abstractionengine/grid/component/GridMomentum.java b/core/src/main/java/coffee/liz/abstractionengine/grid/component/GridMomentum.java new file mode 100644 index 0000000..aec26b1 --- /dev/null +++ b/core/src/main/java/coffee/liz/abstractionengine/grid/component/GridMomentum.java @@ -0,0 +1,17 @@ +package coffee.liz.abstractionengine.grid.component; + +import coffee.liz.ecs.math.Vec2; +import coffee.liz.ecs.model.Component; + +import lombok.Data; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** {@link Component} for grid momentum/velocity. */ +@RequiredArgsConstructor +@Data +@Getter +public class GridMomentum implements Component { + /** Velocity {@link Vec2}. */ + private final Vec2<Integer> velocity; +} diff --git a/core/src/main/java/coffee/liz/abstractionengine/grid/component/GridPosition.java b/core/src/main/java/coffee/liz/abstractionengine/grid/component/GridPosition.java new file mode 100644 index 0000000..4ec0557 --- /dev/null +++ b/core/src/main/java/coffee/liz/abstractionengine/grid/component/GridPosition.java @@ -0,0 +1,16 @@ +package coffee.liz.abstractionengine.grid.component; + +import coffee.liz.ecs.math.Vec2; +import coffee.liz.ecs.model.Component; + +import lombok.Data; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** {@link Component} for grid position. */ +@RequiredArgsConstructor +@Data +@Getter +public class GridPosition implements Component { + private final Vec2<Integer> position; +} diff --git a/core/src/main/java/coffee/liz/abstractionengine/grid/system/BaseGridIndexSystem.java b/core/src/main/java/coffee/liz/abstractionengine/grid/system/BaseGridIndexSystem.java new file mode 100644 index 0000000..61044f6 --- /dev/null +++ b/core/src/main/java/coffee/liz/abstractionengine/grid/system/BaseGridIndexSystem.java @@ -0,0 +1,55 @@ +package coffee.liz.abstractionengine.grid.system; + +import coffee.liz.abstractionengine.grid.component.GridPosition; +import coffee.liz.ecs.math.Mat2; +import coffee.liz.ecs.math.Vec2; +import coffee.liz.ecs.model.Entity; +import coffee.liz.ecs.model.System; +import coffee.liz.ecs.model.World; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.time.Duration; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** System which maintains a lookup of the grid. */ +@RequiredArgsConstructor +@Getter +public abstract class BaseGridIndexSystem<T> implements System<T> { + protected final Vec2<Integer> dimensions; + protected final List<List<Set<Entity>>> rows; + + public BaseGridIndexSystem(final Vec2<Integer> dimensions) { + this.dimensions = dimensions; + this.rows = Mat2.init(dimensions, pos -> new HashSet<>()); + } + + @Override + public Collection<Class<? extends System<T>>> getDependencies() { + return Set.of(); + } + + @Override + public void update(final World<T> world, final T state, final Duration dt) { + rows.forEach(row -> row.forEach(Set::clear)); + world.query(Set.of(GridPosition.class)).forEach(entity -> { + final GridPosition position = entity.get(GridPosition.class); + rows.get(position.getPosition().getY()).get(position.getPosition().getX()).add(entity); + }); + } + + public Collection<Entity> entitiesAt(final Vec2<Integer> pos) { + if (!inBounds(pos)) { + return Collections.emptySet(); + } + return Set.copyOf(rows.get(pos.getY()).get(pos.getX())); + } + + public boolean inBounds(final Vec2<Integer> pos) { + return pos.getX() >= 0 && pos.getX() < dimensions.getX() && pos.getY() >= 0 && pos.getY() < dimensions.getY(); + } +} diff --git a/core/src/main/java/coffee/liz/abstractionengine/grid/system/GridCollisionPropagatationSystem.java b/core/src/main/java/coffee/liz/abstractionengine/grid/system/GridCollisionPropagatationSystem.java new file mode 100644 index 0000000..9d07b59 --- /dev/null +++ b/core/src/main/java/coffee/liz/abstractionengine/grid/system/GridCollisionPropagatationSystem.java @@ -0,0 +1,113 @@ +package coffee.liz.abstractionengine.grid.system; + +import coffee.liz.abstractionengine.grid.component.GridCollidable; +import coffee.liz.abstractionengine.grid.component.GridCollidable.CollisionBehavior; +import coffee.liz.abstractionengine.grid.component.GridInputState; +import coffee.liz.abstractionengine.grid.component.GridMomentum; +import coffee.liz.abstractionengine.grid.component.GridPosition; +import coffee.liz.ecs.math.Vec2; +import coffee.liz.ecs.math.Vec2i; +import coffee.liz.ecs.model.Entity; +import coffee.liz.ecs.model.System; +import coffee.liz.ecs.model.World; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * System which resolves collisions between cells in the Grid. + */ +public class GridCollisionPropagatationSystem implements System<GridInputState> { + @Override + public Collection<Class<? extends System<GridInputState>>> getDependencies() { + return Set.of(GridMovementSystem.class, GridIndexSystem.class); + } + + @Override + public void update(final World<GridInputState> world, final GridInputState state, final Duration dt) { + final GridIndexSystem indexSystem = world.getSystem(GridIndexSystem.class); + world.query(Set.of(GridMomentum.class, GridPosition.class, GridCollidable.class)).forEach(pusher -> { + final Vec2<Integer> velocity = pusher.get(GridMomentum.class).getVelocity(); + if (velocity.equals(Vec2i.ZERO)) { + return; + } + + final Vec2<Integer> position = pusher.get(GridPosition.class).getPosition(); + final CollisionRayResult result = resolveCollisionRay(world, pusher, position, velocity); + + pusher.add(new GridMomentum(Vec2i.ZERO)); + if (result instanceof CollisionRayResult.Blocked) { + return; + } + + final CollisionRayResult.Propagated propagated = (CollisionRayResult.Propagated) result; + propagated.ray().forEach(e -> e.add(new GridMomentum(propagated.direction()))); + propagated.swallows() + .forEach(entry -> entry.getKey().get(GridCollidable.class).onSwallow(entry.getValue(), world)); + }); + } + + private CollisionRayResult resolveCollisionRay(World<GridInputState> world, Entity pusher, Vec2<Integer> start, + Vec2<Integer> direction) { + final GridIndexSystem gridIndex = world.getSystem(GridIndexSystem.class); + + final List<List<Entity>> ray = new ArrayList<>(); + ray.add(List.of(pusher)); + final Set<Map.Entry<Entity, Entity>> swallows = new HashSet<>(); + + Vec2<Integer> gridPosition = start.plus(direction); + while (!ray.getLast().isEmpty()) { + if (!gridIndex.inBounds(gridPosition)) { + return new CollisionRayResult.Blocked(); + } + + final List<Entity> collidables = gridIndex.entitiesAt(gridPosition).stream() + .filter(e -> e.has(GridCollidable.class)).toList(); + if (collidables.isEmpty()) { + break; + } + + final List<Entity> nextRay = new ArrayList<>(); + + for (final Entity push : ray.getLast()) { + final Map.Entry<Entity, CollisionBehavior> behavior = collidables.stream() + .map(c -> Map.entry(c, c.get(GridCollidable.class).getCollisionBehaviorBetween(push, c))) + .min(Comparator.comparing(e -> e.getValue().getPriority())).orElseThrow(); + + switch (behavior.getValue().getCollisionBehaviorType()) { + case PROPAGATE -> { + nextRay.add(behavior.getKey()); + } + case SWALLOW -> { + swallows.add(Map.entry(behavior.getKey(), push)); + } + case WALL -> { + return new CollisionRayResult.Blocked(); + } + } + } + + ray.add(nextRay); + gridPosition = gridPosition.plus(direction); + } + + final Collection<Entity> rayEntities = ray.stream().flatMap(Collection::stream).toList(); + return new CollisionRayResult.Propagated(rayEntities, swallows, direction); + } + + private sealed interface CollisionRayResult permits CollisionRayResult.Propagated, CollisionRayResult.Blocked { + + record Propagated(Collection<Entity> ray, Set<Map.Entry<Entity, Entity>> swallows, + Vec2<Integer> direction) implements CollisionRayResult { + } + + record Blocked() implements CollisionRayResult { + } + } +} diff --git a/core/src/main/java/coffee/liz/abstractionengine/grid/system/GridIndexSystem.java b/core/src/main/java/coffee/liz/abstractionengine/grid/system/GridIndexSystem.java new file mode 100644 index 0000000..ca2279f --- /dev/null +++ b/core/src/main/java/coffee/liz/abstractionengine/grid/system/GridIndexSystem.java @@ -0,0 +1,10 @@ +package coffee.liz.abstractionengine.grid.system; + +import coffee.liz.abstractionengine.grid.component.GridInputState; +import coffee.liz.ecs.math.Vec2; + +public class GridIndexSystem extends BaseGridIndexSystem<GridInputState> { + public GridIndexSystem(final Vec2<Integer> dimensions) { + super(dimensions); + } +} diff --git a/core/src/main/java/coffee/liz/abstractionengine/grid/system/GridMovementSystem.java b/core/src/main/java/coffee/liz/abstractionengine/grid/system/GridMovementSystem.java new file mode 100644 index 0000000..f5f1089 --- /dev/null +++ b/core/src/main/java/coffee/liz/abstractionengine/grid/system/GridMovementSystem.java @@ -0,0 +1,31 @@ +package coffee.liz.abstractionengine.grid.system; + +import coffee.liz.abstractionengine.grid.component.GridControllable; +import coffee.liz.abstractionengine.grid.component.GridInputState; +import coffee.liz.abstractionengine.grid.component.GridMomentum; +import coffee.liz.abstractionengine.grid.component.GridPosition; +import coffee.liz.ecs.model.System; +import coffee.liz.ecs.model.World; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.time.Duration; +import java.util.Collection; +import java.util.Set; + +/** System handling player movement input. */ +@RequiredArgsConstructor +@Getter +public class GridMovementSystem implements System<GridInputState> { + @Override + public Collection<Class<? extends System<GridInputState>>> getDependencies() { + return Set.of(); + } + + @Override + public void update(final World<GridInputState> world, final GridInputState state, final Duration dt) { + world.query(Set.of(GridControllable.class, GridPosition.class)) + .forEach(entity -> entity.add(new GridMomentum(state.getMovement()))); + } +} diff --git a/core/src/main/java/coffee/liz/abstractionengine/grid/system/GridPhysicsSystem.java b/core/src/main/java/coffee/liz/abstractionengine/grid/system/GridPhysicsSystem.java new file mode 100644 index 0000000..88f8d57 --- /dev/null +++ b/core/src/main/java/coffee/liz/abstractionengine/grid/system/GridPhysicsSystem.java @@ -0,0 +1,31 @@ +package coffee.liz.abstractionengine.grid.system; + +import coffee.liz.abstractionengine.grid.component.GridInputState; +import coffee.liz.abstractionengine.grid.component.GridMomentum; +import coffee.liz.abstractionengine.grid.component.GridPosition; +import coffee.liz.ecs.math.Vec2i; +import coffee.liz.ecs.model.System; +import coffee.liz.ecs.model.World; + +import java.time.Duration; +import java.util.Collection; +import java.util.Set; + +/** System applying physics/position updates from momentum. */ +public class GridPhysicsSystem implements System<GridInputState> { + @Override + public Collection<Class<? extends System<GridInputState>>> getDependencies() { + return Set.of(GridCollisionPropagatationSystem.class); + } + + @Override + public void update(final World<GridInputState> world, final GridInputState state, final Duration dt) { + world.query(Set.of(GridMomentum.class, GridPosition.class)).forEach(entity -> { + final GridMomentum momentum = entity.get(GridMomentum.class); + final GridPosition position = entity.get(GridPosition.class); + + entity.add(new GridPosition(position.getPosition().plus(momentum.getVelocity()))); + entity.add(new GridMomentum(Vec2i.ZERO)); + }); + } +} diff --git a/core/src/main/java/coffee/liz/ecs/DAGWorld.java b/core/src/main/java/coffee/liz/ecs/DAGWorld.java new file mode 100644 index 0000000..716808a --- /dev/null +++ b/core/src/main/java/coffee/liz/ecs/DAGWorld.java @@ -0,0 +1,158 @@ +package coffee.liz.ecs; + +import coffee.liz.ecs.model.Component; +import coffee.liz.ecs.model.Entity; +import coffee.liz.ecs.model.System; +import coffee.liz.ecs.model.World; + +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Queue; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; + +/** World that updates in {@link System#getDependencies()} topological order. */ +@Log4j2 +@RequiredArgsConstructor +public class DAGWorld<T> implements World<T> { + /** All entities in the world. */ + protected final Set<Entity> entities = Collections.synchronizedSet(new HashSet<>()); + + /** Cache mapping component types to entities having that component. */ + private final Map<Class<? extends Component>, Set<Entity>> componentCache = Collections + .synchronizedMap(new HashMap<>()); + + /** Deterministic ID's for spawned entities. */ + private final AtomicInteger nextEntityId = new AtomicInteger(0); + + /** All registered systems. */ + protected final Map<Class<? extends System<T>>, System<T>> systems; + + /** Ordered list of systems for execution. */ + private final List<System<T>> systemExecutionOrder; + + public DAGWorld(final Map<Class<? extends System<T>>, System<T>> systems) { + this.systems = systems; + this.systemExecutionOrder = buildExecutionOrder(systems.values().stream().toList()); + log.debug("Executing in order: {}", systemExecutionOrder); + } + + @Override + public Entity createEntity() { + final Entity entity = Entity.builder().id(nextEntityId.incrementAndGet()).build(); + entities.add(entity); + return entity; + } + + @Override + public void removeEntity(final Entity entity) { + entity.getComponentMap().keySet().forEach(componentType -> { + final Set<Entity> cachedEntities = componentCache.get(componentType); + if (cachedEntities != null) { + cachedEntities.remove(entity); + } + }); + entities.remove(entity); + } + + @Override + public Set<Entity> query(final Collection<Class<? extends Component>> components) { + if (components.isEmpty()) { + return Set.copyOf(entities); + } + + final Class<? extends Component> firstType = components.iterator().next(); + final Set<Entity> candidates = componentCache.get(firstType); + if (candidates == null) { + return Collections.emptySet(); + } + + return candidates.stream().filter(entity -> components.stream().allMatch(entity::has)) + .collect(Collectors.toSet()); + } + + @Override + public void update(final T state, final Duration duration) { + refreshComponentCache(); + systemExecutionOrder.forEach(system -> { + system.update(this, state, duration); + }); + } + + @SuppressWarnings("unchecked") + @Override + public <S extends System<T>> S getSystem(final Class<S> system) { + return (S) systems.get(system); + } + + private void refreshComponentCache() { + componentCache.clear(); + entities.forEach(entity -> { + entity.getComponentMap().keySet().forEach(componentType -> { + componentCache.computeIfAbsent(componentType, k -> new HashSet<>()).add(entity); + }); + }); + } + + private List<System<T>> buildExecutionOrder(final Collection<System<T>> systems) { + if (systems.isEmpty()) { + return Collections.emptyList(); + } + + final Map<Class<?>, System<T>> systemMap = systems.stream() + .collect(Collectors.toMap(System::getClass, system -> system)); + final Map<Class<?>, Integer> inDegree = new HashMap<>(); + final Map<Class<?>, Set<Class<?>>> adjacencyList = new HashMap<>(); + + systems.forEach(system -> { + final Class<?> systemClass = system.getClass(); + inDegree.put(systemClass, 0); + adjacencyList.put(systemClass, new HashSet<>()); + }); + + systems.forEach(system -> { + system.getDependencies().forEach(dependency -> { + if (systemMap.containsKey(dependency)) { + adjacencyList.get(dependency).add(system.getClass()); + inDegree.merge(system.getClass(), 1, Integer::sum); + } + }); + }); + + // Kahn's algorithm + final List<System<T>> result = new ArrayList<>(); + + final Queue<Class<?>> queue = new LinkedList<>( + inDegree.entrySet().stream().filter(entry -> entry.getValue() == 0).map(Map.Entry::getKey).toList()); + + while (!queue.isEmpty()) { + final Class<?> currentClass = queue.poll(); + result.add(systemMap.get(currentClass)); + + adjacencyList.getOrDefault(currentClass, Collections.emptySet()).forEach(dependent -> { + final int newInDegree = inDegree.get(dependent) - 1; + inDegree.put(dependent, newInDegree); + if (newInDegree == 0) { + queue.add(dependent); + } + }); + } + + if (result.size() != systems.size()) { + throw new IllegalStateException("Circular dependency detected in systems"); + } + + return Collections.unmodifiableList(result); + } +} diff --git a/core/src/main/java/coffee/liz/ecs/math/Mat2.java b/core/src/main/java/coffee/liz/ecs/math/Mat2.java new file mode 100644 index 0000000..8be945c --- /dev/null +++ b/core/src/main/java/coffee/liz/ecs/math/Mat2.java @@ -0,0 +1,91 @@ +package coffee.liz.ecs.math; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.BiFunction; +import java.util.function.Function; +import java.util.function.Supplier; + +public final class Mat2 { + private Mat2() { + } + + /** + * Initializes a mutable 2d matrix of given type. + * + * @param dimensions + * the dimensions + * @param constructor + * the constructor + * @return row-indexed 2d matrix of {@param dimensions} + */ + public static <T> List<List<T>> init(final Vec2<Integer> dimensions, final Function<Vec2<Integer>, T> constructor) { + final List<List<T>> rows = new ArrayList<>(); + for (int y = 0; y < dimensions.getY(); y++) { + final List<T> row = new ArrayList<>(dimensions.getX()); + for (int x = 0; x < dimensions.getX(); x++) + row.add(constructor.apply(Vec2i.builder().y(y).x(x).build())); + rows.add(row); + } + return rows; + } + + /** + * Convolves a {@link Convolver} across a matrix. + * + * @param mat + * is the row-indexed 2d matrix to convolve. + * @param axes + * are the x/y major/minor axes. + * @param init + * is the initial value of the convolution. + * @param convolver + * is the {@link Convolver}. + * @param finalReduction + * to apply after {@param convolver}. + * @return result of {@param convolver} applied along axes at each cell. + * @param <T> + * is the type of the matrix to convolve. + * @param <R> + * is the type of the resulting type of each convolution. + */ + public static <T, R, U> List<List<U>> convolve(final List<List<T>> mat, final Vec2<Integer> axes, + final Supplier<R> init, final Convolver<T, R> convolver, final BiFunction<T, R, U> finalReduction) { + final List<List<R>> rows = new ArrayList<>(); + for (int y = 0; y < mat.size(); y++) { + final List<R> row = new ArrayList<>(mat.get(y).size()); + for (int x = 0; x < mat.get(y).size(); x++) { + final T center = mat.get(y).get(x); + R result = init.get(); + for (int dy = -axes.getY(); dy <= axes.getY(); dy++) { + final int ry = y + dy; + if (ry < 0 || ry >= mat.size()) + continue; + for (int dx = -axes.getX(); dx <= axes.getX(); dx++) { + final int rx = x + dx; + if (rx < 0 || rx >= mat.get(ry).size()) + continue; + result = convolver.convolve(mat.get(ry).get(rx), Vec2i.builder().x(dx).y(dy).build(), result); + } + } + row.add(result); + } + rows.add(row); + } + + final List<List<U>> reductions = new ArrayList<>(); + for (int y = 0; y < mat.size(); y++) { + final List<U> reduction = new ArrayList<>(mat.get(y).size()); + for (int x = 0; x < mat.get(y).size(); x++) { + reduction.add(finalReduction.apply(mat.get(y).get(x), rows.get(y).get(x))); + } + reductions.add(reduction); + } + return reductions; + } + + @FunctionalInterface + public interface Convolver<T, R> { + R convolve(final T center, final Vec2<Integer> rel, final R reduction); + } +} diff --git a/core/src/main/java/coffee/liz/ecs/math/Vec2.java b/core/src/main/java/coffee/liz/ecs/math/Vec2.java new file mode 100644 index 0000000..ec7e531 --- /dev/null +++ b/core/src/main/java/coffee/liz/ecs/math/Vec2.java @@ -0,0 +1,65 @@ +package coffee.liz.ecs.math; + +/** + * Cartesian vectors. + * + * @param <T> + * the numeric type of vector components + */ +public interface Vec2<T> { + /** + * @return the x coordinate + */ + T getX(); + + /** + * @return the y coordinate + */ + T getY(); + + /** + * Adds another vector to this vector. + * + * @param other + * the vector to add + * @return a new vector with the result + */ + Vec2<T> plus(final Vec2<T> other); + + /** + * Subtracts another vector from this vector. + * + * @param other + * the vector to subtract + * @return a new vector with the result + */ + Vec2<T> minus(final Vec2<T> other); + + /** + * Scales this vector by the given factors. + * + * @param scaleX + * the x scale factor + * @param scaleY + * the y scale factor + * @return a new scaled vector + */ + Vec2<T> scale(final T scaleX, final T scaleY); + + /** + * Length of the vector. + * + * @return length. + */ + float length(); + + /** + * @return Vec2<Integer> components of {@link Vec2<T>} + */ + Vec2<Integer> intValue(); + + /** + * @return Vec2<Float> components of {@link Vec2<T>} + */ + Vec2<Float> floatValue(); +} diff --git a/core/src/main/java/coffee/liz/ecs/math/Vec2f.java b/core/src/main/java/coffee/liz/ecs/math/Vec2f.java new file mode 100644 index 0000000..46f3fb8 --- /dev/null +++ b/core/src/main/java/coffee/liz/ecs/math/Vec2f.java @@ -0,0 +1,54 @@ +package coffee.liz.ecs.math; + +import static java.lang.Math.sqrt; + +import lombok.Builder; +import lombok.Data; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** Float impl of {@link Vec2}. */ +@Getter +@RequiredArgsConstructor +@Data +@Builder +public final class Vec2f implements Vec2<Float> { + /** X coordinate. */ + private final Float x; + + /** Y coordinate. */ + private final Float y; + + @Override + public Vec2<Float> plus(final Vec2<Float> other) { + return new Vec2f(x + other.getX(), y + other.getY()); + } + + @Override + public Vec2<Float> minus(final Vec2<Float> other) { + return new Vec2f(x - other.getX(), y - other.getY()); + } + + @Override + public Vec2<Float> scale(final Float scaleX, final Float scaleY) { + return new Vec2f(x * scaleX, y * scaleY); + } + + @Override + public float length() { + return (float) sqrt(x * x + y * y); + } + + @Override + public Vec2<Float> floatValue() { + return this; + } + + @Override + public Vec2<Integer> intValue() { + return Vec2i.builder().x(this.x.intValue()).y(this.y.intValue()).build(); + } + + /** Zero float vec */ + public static Vec2<Float> ZERO = Vec2f.builder().x(0f).y(0f).build(); +} diff --git a/core/src/main/java/coffee/liz/ecs/math/Vec2i.java b/core/src/main/java/coffee/liz/ecs/math/Vec2i.java new file mode 100644 index 0000000..dbe246e --- /dev/null +++ b/core/src/main/java/coffee/liz/ecs/math/Vec2i.java @@ -0,0 +1,57 @@ +package coffee.liz.ecs.math; + +import static java.lang.Math.sqrt; + +import lombok.Builder; +import lombok.Data; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** Integer impl of {@link Vec2}. */ +@Getter +@RequiredArgsConstructor +@Builder +@Data +public final class Vec2i implements Vec2<Integer> { + /** X coordinate. */ + private final Integer x; + + /** Y coordinate. */ + private final Integer y; + + @Override + public Vec2<Integer> plus(final Vec2<Integer> other) { + return new Vec2i(x + other.getX(), y + other.getY()); + } + + @Override + public Vec2<Integer> minus(final Vec2<Integer> other) { + return new Vec2i(x - other.getX(), y - other.getY()); + } + + @Override + public Vec2<Integer> scale(final Integer scaleX, final Integer scaleY) { + return new Vec2i(x * scaleX, y * scaleY); + } + + @Override + public Vec2<Float> floatValue() { + return Vec2f.builder().x(this.x.floatValue()).y(this.y.floatValue()).build(); + } + + @Override + public Vec2<Integer> intValue() { + return this; + } + + @Override + public float length() { + return (float) sqrt(x * x + y * y); + } + + public static final Vec2i NORTH = new Vec2i(0, -1); + public static final Vec2i SOUTH = new Vec2i(0, 1); + public static final Vec2i EAST = new Vec2i(1, 0); + public static final Vec2i WEST = new Vec2i(-1, 0); + public static final Vec2i ZERO = new Vec2i(0, 0); +} diff --git a/core/src/main/java/coffee/liz/ecs/model/Component.java b/core/src/main/java/coffee/liz/ecs/model/Component.java new file mode 100644 index 0000000..f96ba95 --- /dev/null +++ b/core/src/main/java/coffee/liz/ecs/model/Component.java @@ -0,0 +1,8 @@ +package coffee.liz.ecs.model; + +/** Component of an {@link Entity}. */ +public interface Component { + default Class<? extends Component> getKey() { + return getClass(); + } +} diff --git a/core/src/main/java/coffee/liz/ecs/model/Entity.java b/core/src/main/java/coffee/liz/ecs/model/Entity.java new file mode 100644 index 0000000..e820e57 --- /dev/null +++ b/core/src/main/java/coffee/liz/ecs/model/Entity.java @@ -0,0 +1,105 @@ +package coffee.liz.ecs.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +@Getter +@Builder +@RequiredArgsConstructor +@AllArgsConstructor +@Data +public class Entity { + /** Unique id. */ + private final int id; + + /** Instances of {@link Component}s. */ + @Builder.Default + private Map<Class<? extends Component>, Component> componentMap = Collections.synchronizedMap(new HashMap<>()); + + /** + * Check if entity has component type. + * + * @param componentType + * the {@link Component} class + * @return true if component exists + */ + public boolean has(final Class<? extends Component> componentType) { + return componentMap.containsKey(componentType); + } + + /** + * Check if entity has all component types. + * + * @param components + * collection of {@link Component} classes + * @return true if all components exist + */ + public boolean hasAll(final Collection<Class<? extends Component>> components) { + return components.stream().allMatch(this::has); + } + + /** + * Get component by type. + * + * @param componentType + * the {@link Component} class + * @param <C> + * component type + * @return the component or throw {@link IllegalArgumentException} + */ + @SuppressWarnings("unchecked") + public <C extends Component> C get(final Class<C> componentType) { + final C component = (C) componentMap.get(componentType); + if (component == null) { + throw new IllegalArgumentException( + "Entity with id " + getId() + " does not have required component " + componentType.getSimpleName()); + } + return component; + } + + /** + * Add component to entity. + * + * @param component + * the {@link Component} to add + * @param <C> + * component type + * @return this {@link Entity} for chaining + */ + public <C extends Component> Entity add(final C component) { + componentMap.put(component.getKey(), component); + return this; + } + + /** + * Remove component from entity. + * + * @param componentType + * the {@link Component} class to remove + * @param <C> + * component type + * @return this {@link Entity} for chaining + */ + public <C extends Component> Entity remove(final Class<C> componentType) { + componentMap.remove(componentType); + return this; + } + + /** + * Get all component types. + * + * @return set of {@link Component} classes + */ + public Set<Class<? extends Component>> componentTypes() { + return componentMap.keySet(); + } +} diff --git a/core/src/main/java/coffee/liz/ecs/model/System.java b/core/src/main/java/coffee/liz/ecs/model/System.java new file mode 100644 index 0000000..220b917 --- /dev/null +++ b/core/src/main/java/coffee/liz/ecs/model/System.java @@ -0,0 +1,29 @@ +package coffee.liz.ecs.model; + +import java.time.Duration; +import java.util.Collection; + +/** + * Updates the {@link World} state. + * + * @param <T> + * is the state of the stuff outside the {@link World}. + */ +public interface System<T> { + /** + * {@link System} clazzes that must run before this system. + * + * @return {@link Collection} of dependencies. + */ + Collection<Class<? extends System<T>>> getDependencies(); + + /** + * @param world + * Is the {@link World}. + * @param state + * Is the {@link T} state outside the {@param world}. + * @param dt + * Is the timestep. + */ + void update(final World<T> world, final T state, final Duration dt); +} diff --git a/core/src/main/java/coffee/liz/ecs/model/World.java b/core/src/main/java/coffee/liz/ecs/model/World.java new file mode 100644 index 0000000..96c7a74 --- /dev/null +++ b/core/src/main/java/coffee/liz/ecs/model/World.java @@ -0,0 +1,58 @@ +package coffee.liz.ecs.model; + +import java.time.Duration; +import java.util.Collection; +import java.util.Set; + +/** + * The state of the world. + * + * @param <T> + * is the state of the stuff outside the world. + */ +public interface World<T> { + /** + * Create unique {@link Entity} in the {@link World}. + * + * @return created {@link Entity}. + */ + Entity createEntity(); + + /** + * Remove an entity from the {@link World}. + * + * @param entity + * to remove. + */ + void removeEntity(final Entity entity); + + /** + * Get entities with {@link Component}s. + * + * @param components + * to query for. + * @return All entities with all {@param components}. + */ + Set<Entity> query(final Collection<Class<? extends Component>> components); + + /** + * Integrate the {@link World}. + * + * @param state + * Is the state outside the world. + * @param duration + * Is the time step. + */ + void update(final T state, final Duration duration); + + /** + * Get world {@link System}. + * + * @param system + * is the Clazz. + * @param <S> + * is the {@link System} type. + * @return {@link System} instance of {@param system}. + */ + <S extends System<T>> S getSystem(final Class<S> system); +} diff --git a/core/src/main/java/coffee/liz/lambda/LambdaDriver.java b/core/src/main/java/coffee/liz/lambda/LambdaDriver.java new file mode 100644 index 0000000..2ba8c70 --- /dev/null +++ b/core/src/main/java/coffee/liz/lambda/LambdaDriver.java @@ -0,0 +1,79 @@ +package coffee.liz.lambda; + +import coffee.liz.lambda.parser.ArrowParser; +import coffee.liz.lambda.parser.LambdaParser; +import coffee.liz.lambda.parser.ParseException; +import coffee.liz.lambda.ast.Expression; +import coffee.liz.lambda.ast.LambdaProgram; +import coffee.liz.lambda.ast.Macro; +import coffee.liz.lambda.ast.SourceCode; +import coffee.liz.lambda.bind.ExternalBinding; +import coffee.liz.lambda.eval.Environment; +import coffee.liz.lambda.eval.NormalOrderEvaluator; +import coffee.liz.lambda.eval.Value; + +import java.io.StringReader; +import java.util.List; + +/** + * Entry point for parsing and interpreting lambda calculus programs. + */ +public class LambdaDriver { + + /** + * Parses source code into an AST. + * + * @param sourceCode + * the source code (either Lambda or Arrow syntax) + * @return the parsed program + */ + public static LambdaProgram parse(final SourceCode sourceCode) { + return switch (sourceCode) { + case SourceCode.Lambda(String source) -> parseLambda(source); + case SourceCode.Arrow(String source) -> parseArrow(source); + }; + } + + private static LambdaProgram parseLambda(final String source) { + try (final StringReader reader = new StringReader(source)) { + return new LambdaParser(reader).Program(); + } catch (final ParseException parseException) { + throw new RuntimeException("Failed to parse program", parseException); + } + } + + private static LambdaProgram parseArrow(final String source) { + try (final StringReader reader = new StringReader(source)) { + return new ArrowParser(reader).Program(); + } catch (final ParseException parseException) { + throw new RuntimeException("Failed to parse program", parseException); + } + } + + /** + * Parses and evaluates lambda calculus programs. + * + * @param sourceCode + * the source code + * @return the evaluated result + */ + public static Value interpret(final SourceCode sourceCode) { + return interpret(sourceCode, List.of()); + } + + /** + * Parses and evaluates lambda calculus programs with "FFI"'s. + * + * @param sourceCode + * the source code + * @param bindings + * external Java functions available during evaluation + * @return the evaluated result + */ + public static Value interpret(final SourceCode sourceCode, final List<ExternalBinding> bindings) { + final LambdaProgram program = parse(sourceCode); + final Expression expression = program.expression(); + final List<Macro> macros = program.macros(); + return NormalOrderEvaluator.evaluate(expression, Environment.from(macros, bindings)); + } +} diff --git a/core/src/main/java/coffee/liz/lambda/ast/Expression.java b/core/src/main/java/coffee/liz/lambda/ast/Expression.java new file mode 100644 index 0000000..6d75a08 --- /dev/null +++ b/core/src/main/java/coffee/liz/lambda/ast/Expression.java @@ -0,0 +1,28 @@ +package coffee.liz.lambda.ast; + +import lombok.NonNull; + +import java.util.Optional; + +/** + * Represents an expression in the untyped lambda calculus. + */ +public sealed interface Expression + permits Expression.AbstractionExpression, Expression.IdentifierExpression, Expression.ApplicationExpression { + + Optional<SourceComment> comment(); + + SourceSpan span(); + + record AbstractionExpression(@NonNull Optional<SourceComment> comment, @NonNull SourceSpan span, + @NonNull String parameter, @NonNull Expression body) implements Expression { + } + + record ApplicationExpression(@NonNull Optional<SourceComment> comment, @NonNull SourceSpan span, + @NonNull Expression applicable, @NonNull Expression argument) implements Expression { + } + + record IdentifierExpression(@NonNull Optional<SourceComment> comment, @NonNull SourceSpan span, + @NonNull String name) implements Expression { + } +} diff --git a/core/src/main/java/coffee/liz/lambda/ast/LambdaProgram.java b/core/src/main/java/coffee/liz/lambda/ast/LambdaProgram.java new file mode 100644 index 0000000..efd4c03 --- /dev/null +++ b/core/src/main/java/coffee/liz/lambda/ast/LambdaProgram.java @@ -0,0 +1,19 @@ +package coffee.liz.lambda.ast; + +import lombok.NonNull; + +import java.util.List; + +/** + * A complete lambda calculus program consisting of macro definitions and a main + * expression. + * + * @param span + * source span covering the entire program + * @param macros + * named macro definitions that can be referenced in the expression + * @param expression + * the main expression to evaluate + */ +public record LambdaProgram(@NonNull SourceSpan span, @NonNull List<Macro> macros, @NonNull Expression expression) { +} diff --git a/core/src/main/java/coffee/liz/lambda/ast/Macro.java b/core/src/main/java/coffee/liz/lambda/ast/Macro.java new file mode 100644 index 0000000..07ba911 --- /dev/null +++ b/core/src/main/java/coffee/liz/lambda/ast/Macro.java @@ -0,0 +1,12 @@ +package coffee.liz.lambda.ast; + +import lombok.NonNull; + +import java.util.Optional; + +/** + * A named macro definition that maps an identifier to an expression. + */ +public record Macro(@NonNull Optional<SourceComment> comment, @NonNull SourceSpan span, @NonNull String name, + @NonNull Expression expression) { +} diff --git a/core/src/main/java/coffee/liz/lambda/ast/SourceCode.java b/core/src/main/java/coffee/liz/lambda/ast/SourceCode.java new file mode 100644 index 0000000..200c45e --- /dev/null +++ b/core/src/main/java/coffee/liz/lambda/ast/SourceCode.java @@ -0,0 +1,27 @@ +package coffee.liz.lambda.ast; + +/** + * Represents source code in one of the supported lambda calculus syntaxes. + */ +public sealed interface SourceCode { + static SourceCode ofLambda(final String source) { + return new Lambda(source); + } + + static SourceCode ofArrow(final String source) { + return new Arrow(source); + } + + record Lambda(String source) implements SourceCode { + } + + record Arrow(String source) implements SourceCode { + } + + /** + * Supported syntax types for {@link SourceCode}. + */ + enum Syntax { + LAMBDA, ARROW + } +} diff --git a/core/src/main/java/coffee/liz/lambda/ast/SourceComment.java b/core/src/main/java/coffee/liz/lambda/ast/SourceComment.java new file mode 100644 index 0000000..da6b5ab --- /dev/null +++ b/core/src/main/java/coffee/liz/lambda/ast/SourceComment.java @@ -0,0 +1,21 @@ +package coffee.liz.lambda.ast; + +import lombok.NonNull; + +/** + * A comment with its source location; inline vs leading direction. + * + * @param text + * the comment text content + * @param span + * the source location of the comment + */ +public record SourceComment(@NonNull String text, @NonNull SourceSpan span) { + + /** + * Returns true if this comment is on the same line as the given span's end. + */ + public boolean isInlineAfter(final SourceSpan previous) { + return previous != null && previous.endLine() == this.span.startLine(); + } +} diff --git a/core/src/main/java/coffee/liz/lambda/ast/SourceSpan.java b/core/src/main/java/coffee/liz/lambda/ast/SourceSpan.java new file mode 100644 index 0000000..7df9bcd --- /dev/null +++ b/core/src/main/java/coffee/liz/lambda/ast/SourceSpan.java @@ -0,0 +1,24 @@ +package coffee.liz.lambda.ast; + +/** + * Span of source code with start and end positions. + * + * @param startLine + * 1-based line number where the span starts + * @param startColumn + * 1-based column number where the span starts + * @param endLine + * 1-based line number where the span ends + * @param endColumn + * 1-based column number where the span ends + */ +public record SourceSpan(int startLine, int startColumn, int endLine, int endColumn) { + public static final SourceSpan UNKNOWN = new SourceSpan(0, 0, 0, 0); + + /** + * Returns true if this span ends on the same line that the other span starts. + */ + public boolean isOnSameLine(final SourceSpan other) { + return this.endLine == other.startLine; + } +} diff --git a/core/src/main/java/coffee/liz/lambda/bind/ExternalBinding.java b/core/src/main/java/coffee/liz/lambda/bind/ExternalBinding.java new file mode 100644 index 0000000..4895972 --- /dev/null +++ b/core/src/main/java/coffee/liz/lambda/bind/ExternalBinding.java @@ -0,0 +1,23 @@ +package coffee.liz.lambda.bind; + +import coffee.liz.lambda.eval.Environment; +import coffee.liz.lambda.eval.Value; + +import java.util.function.BiFunction; + +/** + * Interface for external Java functions callable from lambda expressions. + * + * <p> + * Implementations receive the current environment and an argument value, and + * return a result value. + */ +public interface ExternalBinding extends BiFunction<Environment, Value, Value> { + + /** + * Returns the name used to reference this binding in environment. + * + * @return the binding name + */ + String getName(); +} diff --git a/core/src/main/java/coffee/liz/lambda/bind/Tick.java b/core/src/main/java/coffee/liz/lambda/bind/Tick.java new file mode 100644 index 0000000..0fa4de5 --- /dev/null +++ b/core/src/main/java/coffee/liz/lambda/bind/Tick.java @@ -0,0 +1,22 @@ +package coffee.liz.lambda.bind; + +import coffee.liz.lambda.eval.Environment; +import coffee.liz.lambda.eval.Value; +import lombok.Getter; + +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Identity function that has a side effect which internally counts invocations. + */ +@Getter +public class Tick implements ExternalBinding { + private final String name = "Tick"; + private final AtomicInteger counter = new AtomicInteger(0); + + @Override + public Value apply(final Environment environment, final Value value) { + counter.incrementAndGet(); + return value; + } +} diff --git a/core/src/main/java/coffee/liz/lambda/bind/ToChurch.java b/core/src/main/java/coffee/liz/lambda/bind/ToChurch.java new file mode 100644 index 0000000..bfddd86 --- /dev/null +++ b/core/src/main/java/coffee/liz/lambda/bind/ToChurch.java @@ -0,0 +1,49 @@ +package coffee.liz.lambda.bind; + +import coffee.liz.lambda.ast.Expression; +import coffee.liz.lambda.ast.Expression.IdentifierExpression; +import coffee.liz.lambda.ast.Expression.AbstractionExpression; +import coffee.liz.lambda.ast.Expression.ApplicationExpression; +import coffee.liz.lambda.ast.SourceSpan; +import coffee.liz.lambda.eval.Environment; + +import java.util.Optional; +import coffee.liz.lambda.eval.Value; +import coffee.liz.lambda.eval.Value.Free; +import coffee.liz.lambda.eval.Value.Closure; +import lombok.Getter; + +/** + * Converts an integer to its Church numeral representation. + * + * <p> + * Church numerals encode n as {@code λf.λx.f(f(...f(x)...))} with n + * applications of f. + */ +@Getter +public class ToChurch implements ExternalBinding { + private final String name = "ToChurch"; + + /** + * Converts a free variable containing an integer string to a Church numeral. + * + * @param env + * the current environment + * @param val + * a Free value whose name is an integer string + * @return a Closure representing the Church numeral + */ + @Override + public Value apply(final Environment env, final Value val) { + final Free free = (Free) val; + final int n = Integer.parseInt(free.name()); + + Expression body = new IdentifierExpression(Optional.empty(), SourceSpan.UNKNOWN, "x"); + for (int i = 0; i < n; i++) { + body = new ApplicationExpression(Optional.empty(), SourceSpan.UNKNOWN, + new IdentifierExpression(Optional.empty(), SourceSpan.UNKNOWN, "f"), body); + } + + return new Closure(env, "f", new AbstractionExpression(Optional.empty(), SourceSpan.UNKNOWN, "x", body)); + } +} diff --git a/core/src/main/java/coffee/liz/lambda/eval/Environment.java b/core/src/main/java/coffee/liz/lambda/eval/Environment.java new file mode 100644 index 0000000..8854719 --- /dev/null +++ b/core/src/main/java/coffee/liz/lambda/eval/Environment.java @@ -0,0 +1,109 @@ +package coffee.liz.lambda.eval; + +import coffee.liz.lambda.ast.Expression; +import coffee.liz.lambda.ast.Macro; +import coffee.liz.lambda.bind.ExternalBinding; +import jakarta.annotation.Nullable; +import lombok.RequiredArgsConstructor; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +/** + * Runtime environment for variable bindings, macros, and external bindings. + */ +@RequiredArgsConstructor +public final class Environment { + /** Named expansions */ + private final Map<String, Expression> macros; + + /** "FFI" */ + private final Map<String, ExternalBinding> externalBindings; + + /** Variable name bound at this scope level. Null for root. */ + @Nullable + private final String boundName; + + /** Lazily-evaluated value for boundName, or null for root. */ + @Nullable + private final Supplier<Value> boundValue; + + /** Enclosing scope, or null for root. Forms a linked list of bindings. */ + @Nullable + private final Environment parent; + + /** + * Creates an environment from macro and external binding lists. + * + * @param macros + * program macro definitions + * @param externalBindings + * external Java bindings for FFI + * @return the new environment + */ + public static Environment from(final List<Macro> macros, final List<ExternalBinding> externalBindings) { + return new Environment(macros.stream().collect(Collectors.toMap(Macro::name, Macro::expression)), + externalBindings.stream().collect(Collectors.toMap(ExternalBinding::getName, Function.identity())), + null, null, null); + } + + /** + * Creates a child scope. + * + * @param name + * the variable name + * @param value + * the value supplier (thunk) + * @return a new environment with the binding added + */ + public Environment extend(final String name, final Supplier<Value> value) { + return new Environment(macros, externalBindings, name, value, this); + } + + /** + * Looks up a name, checking bindings, then macros, then external bindings. + * + * @param name + * the name to look up + * @return the lookup result, or empty if not found + */ + public Optional<LookupResult> lookup(final String name) { + for (Environment env = this; env != null; env = env.parent) { + if (!name.equals(env.boundName)) { + continue; + } + return Optional.of(new LookupResult.Binding(env.boundValue)); + } + + final Expression macro = macros.get(name); + if (macro != null) { + return Optional.of(new LookupResult.Macro(macro)); + } + + final ExternalBinding external = externalBindings.get(name); + if (external != null) { + return Optional.of(new LookupResult.External(external)); + } + + return Optional.empty(); + } + + /** + * Result of looking up a name in the environment. + */ + public sealed interface LookupResult { + /** A local variable binding. */ + record Binding(Supplier<Value> value) implements LookupResult { + } + /** A macro definition. */ + record Macro(Expression expression) implements LookupResult { + } + /** An external Java binding. */ + record External(ExternalBinding binding) implements LookupResult { + } + } +} diff --git a/core/src/main/java/coffee/liz/lambda/eval/EvaluationDepthExceededException.java b/core/src/main/java/coffee/liz/lambda/eval/EvaluationDepthExceededException.java new file mode 100644 index 0000000..7926004 --- /dev/null +++ b/core/src/main/java/coffee/liz/lambda/eval/EvaluationDepthExceededException.java @@ -0,0 +1,16 @@ +package coffee.liz.lambda.eval; + +import lombok.Getter; + +/** + * Thrown before we get a {@link StackOverflowError}, hopefully. + */ +@Getter +public final class EvaluationDepthExceededException extends RuntimeException { + private final int maxDepth; + + public EvaluationDepthExceededException(final int maxDepth) { + super("Evaluation exceeded maximum depth of " + maxDepth); + this.maxDepth = maxDepth; + } +} diff --git a/core/src/main/java/coffee/liz/lambda/eval/NormalOrderEvaluator.java b/core/src/main/java/coffee/liz/lambda/eval/NormalOrderEvaluator.java new file mode 100644 index 0000000..28eb69b --- /dev/null +++ b/core/src/main/java/coffee/liz/lambda/eval/NormalOrderEvaluator.java @@ -0,0 +1,101 @@ +package coffee.liz.lambda.eval; + +import coffee.liz.lambda.ast.Expression; +import coffee.liz.lambda.ast.Expression.AbstractionExpression; +import coffee.liz.lambda.ast.Expression.ApplicationExpression; +import coffee.liz.lambda.ast.Expression.IdentifierExpression; +import coffee.liz.lambda.bind.ExternalBinding; +import coffee.liz.lambda.eval.Environment.LookupResult; +import coffee.liz.lambda.eval.Value.Application; +import coffee.liz.lambda.eval.Value.Closure; +import coffee.liz.lambda.eval.Value.Free; + +/** + * Evaluates lambda expressions using normal order (lazy) reduction. + * + * <p> + * Arguments are wrapped in {@link Thunk} and only evaluated when needed. + */ +public final class NormalOrderEvaluator { + + public static final int DEFAULT_MAX_DEPTH = 140; + + /** + * Evaluates an expression in the given environment with default depth limit. + * + * @param term + * the expression to evaluate + * @param env + * the environment containing bindings + * @return the resulting value + * @throws EvaluationDepthExceededException + * if evaluation exceeds {@link #DEFAULT_MAX_DEPTH} + */ + public static Value evaluate(final Expression term, final Environment env) { + return evaluate(term, env, DEFAULT_MAX_DEPTH); + } + + /** + * Evaluates an expression in the given environment with specified depth limit. + * + * @param term + * the expression to evaluate + * @param env + * the environment containing bindings + * @param maxDepth + * maximum evaluation depth + * @return the resulting value + * @throws EvaluationDepthExceededException + * if evaluation exceeds maxDepth + */ + public static Value evaluate(final Expression term, final Environment env, final int maxDepth) { + return evaluate(term, env, maxDepth, 0); + } + + private static Value evaluate(final Expression term, final Environment env, final int maxDepth, final int depth) { + if (depth > maxDepth) { + throw new EvaluationDepthExceededException(maxDepth); + } + + return switch (term) { + case IdentifierExpression(var _, var _, final String name) -> + env.lookup(name).map(result -> switch (result) { + case LookupResult.Binding(final var value) -> value.get(); + case LookupResult.Macro(final var expr) -> evaluate(expr, env, maxDepth, depth + 1); + case LookupResult.External(final var binding) -> new Free(binding.getName()); + }).orElseGet(() -> new Free(name)); + + case AbstractionExpression(var _, var _, final String parameter, final Expression body) -> + new Closure(env, parameter, body); + + case ApplicationExpression(var _, var _, final Expression func, final Expression arg) -> + apply(evaluate(func, env, maxDepth, depth + 1), arg, env, maxDepth, depth + 1); + }; + } + + private static Value apply(final Value funcVal, final Expression arg, final Environment argEnv, final int maxDepth, + final int depth) { + if (depth > maxDepth) { + throw new EvaluationDepthExceededException(maxDepth); + } + + return switch (funcVal) { + case Closure(final Environment closureEnv, final String parameter, final Expression body) -> { + final Thunk<Value> thunk = new Thunk<>(() -> evaluate(arg, argEnv, maxDepth, depth + 1)); + final Environment newEnv = closureEnv.extend(parameter, thunk); + yield evaluate(body, newEnv, maxDepth, depth + 1); + } + + case Free(final String name) -> + argEnv.lookup(name).filter(r -> r instanceof LookupResult.External).map(r -> { + final ExternalBinding binding = ((LookupResult.External) r).binding(); + return binding.apply(argEnv, evaluate(arg, argEnv, maxDepth, depth + 1)); + }).orElseGet(() -> new Application(funcVal, evaluate(arg, argEnv, maxDepth, depth + 1))); + + case Application app -> { + final Value argVal = evaluate(arg, argEnv, maxDepth, depth + 1); + yield new Application(app, argVal); + } + }; + } +} diff --git a/core/src/main/java/coffee/liz/lambda/eval/Thunk.java b/core/src/main/java/coffee/liz/lambda/eval/Thunk.java new file mode 100644 index 0000000..07c89bf --- /dev/null +++ b/core/src/main/java/coffee/liz/lambda/eval/Thunk.java @@ -0,0 +1,27 @@ +package coffee.liz.lambda.eval; + +import lombok.RequiredArgsConstructor; + +import java.util.function.Supplier; + +/** + * A memoizing thunk for lazy evaluation. + * + * @param <T> + * Thunk type + */ +@RequiredArgsConstructor +public final class Thunk<T> implements Supplier<T> { + private final Supplier<T> thinker; // https://www.youtube.com/shorts/Dzksib8YxSY + private T cached = null; + private boolean evaluated = false; + + @Override + public T get() { + if (!evaluated) { + cached = thinker.get(); + evaluated = true; + } + return cached; + } +}
\ No newline at end of file diff --git a/core/src/main/java/coffee/liz/lambda/eval/Value.java b/core/src/main/java/coffee/liz/lambda/eval/Value.java new file mode 100644 index 0000000..58b9ba4 --- /dev/null +++ b/core/src/main/java/coffee/liz/lambda/eval/Value.java @@ -0,0 +1,42 @@ +package coffee.liz.lambda.eval; + +import coffee.liz.lambda.ast.Expression; + +/** + * Represents a runtime value produced by evaluation. + */ +public sealed interface Value permits Value.Closure, Value.Application, Value.Free { + + /** + * A closure capturing an environment, parameter, and body. + * + * @param env + * the captured environment + * @param parameter + * the bound parameter name + * @param body + * the lambda body expression + */ + record Closure(Environment env, String parameter, Expression body) implements Value { + } + + /** + * A symbolic application of a function to an argument. + * + * @param function + * the function + * @param argument + * the argument + */ + record Application(Value function, Value argument) implements Value { + } + + /** + * A free variable. + * + * @param name + * the variable name + */ + record Free(String name) implements Value { + } +} diff --git a/core/src/main/java/coffee/liz/lambda/format/Formatter.java b/core/src/main/java/coffee/liz/lambda/format/Formatter.java new file mode 100644 index 0000000..1ac3e95 --- /dev/null +++ b/core/src/main/java/coffee/liz/lambda/format/Formatter.java @@ -0,0 +1,238 @@ +package coffee.liz.lambda.format; + +import coffee.liz.lambda.ast.Expression; +import coffee.liz.lambda.ast.Expression.AbstractionExpression; +import coffee.liz.lambda.ast.Expression.ApplicationExpression; +import coffee.liz.lambda.ast.Expression.IdentifierExpression; +import coffee.liz.lambda.ast.LambdaProgram; +import coffee.liz.lambda.ast.Macro; +import coffee.liz.lambda.ast.SourceComment; +import coffee.liz.lambda.ast.SourceSpan; +import lombok.RequiredArgsConstructor; +import coffee.liz.lambda.ast.SourceCode.Syntax; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +/** + * Formats lambda calculus ASTs. + */ +@RequiredArgsConstructor +public final class Formatter { + + private final Syntax syntax; + + public String format(final LambdaProgram program) { + final StringBuilder sb = new StringBuilder(); + SourceSpan previousEnd = null; + + for (int i = 0; i < program.macros().size(); i++) { + final Macro macro = program.macros().get(i); + + if (macro.comment().isPresent()) { + final SourceComment comment = macro.comment().get(); + final String leadingPart = getLeadingCommentPart(comment, previousEnd); + if (!leadingPart.isEmpty()) { + sb.append(leadingPart); + if (!leadingPart.endsWith("\n")) { + sb.append("\n"); + } + } + } + + sb.append("let ").append(macro.name()).append(" = "); + sb.append(formatExpression(macro.expression(), false)); + sb.append(";"); + + final Optional<String> inlineComment = getInlineCommentAfter(macro.span(), program, i); + inlineComment.ifPresent(c -> sb.append(" ").append(c)); + sb.append("\n"); + + previousEnd = macro.span(); + } + + boolean isTrailingComment = false; + boolean inlinePartAlreadyOutput = false; + + if (program.expression().comment().isPresent()) { + final SourceComment comment = program.expression().comment().get(); + + isTrailingComment = comment.span().startLine() >= program.expression().span().startLine() + && comment.span().startColumn() > program.expression().span().startColumn(); + + if (!isTrailingComment) { + inlinePartAlreadyOutput = previousEnd != null && comment.isInlineAfter(previousEnd); + + final String leadingPart = getLeadingCommentPart(comment, previousEnd); + if (!leadingPart.isEmpty()) { + sb.append(leadingPart); + if (!leadingPart.endsWith("\n")) { + sb.append("\n"); + } + } else if (!program.macros().isEmpty()) { + sb.append("\n"); + } + } else if (!program.macros().isEmpty()) { + sb.append("\n"); + } + } else if (!program.macros().isEmpty()) { + sb.append("\n"); + } + + sb.append(formatExpression(program.expression(), false)); + + if (program.expression().comment().isPresent() && !inlinePartAlreadyOutput && isTrailingComment) { + sb.append(" ").append(program.expression().comment().get().text()); + } + + return sb.toString(); + } + + /** + * Gets the inline portion of a comment (first line if on same line as previous + * element). + */ + private String getInlineCommentPart(final SourceComment comment, final SourceSpan previousEnd) { + if (previousEnd == null || !comment.isInlineAfter(previousEnd)) { + return ""; + } + final String text = comment.text(); + final int newlineIndex = text.indexOf('\n'); + return newlineIndex == -1 ? text : text.substring(0, newlineIndex); + } + + /** + * Gets the leading portion of a comment (all lines except inline first line). + */ + private String getLeadingCommentPart(final SourceComment comment, final SourceSpan previousEnd) { + if (previousEnd == null || !comment.isInlineAfter(previousEnd)) { + return comment.text(); + } + final String text = comment.text(); + final int newlineIndex = text.indexOf('\n'); + return newlineIndex == -1 ? "" : text.substring(newlineIndex + 1); + } + + /** + * Gets the inline comment that appears after the given span, if any. + */ + private Optional<String> getInlineCommentAfter(final SourceSpan span, final LambdaProgram program, + final int macroIndex) { + if (macroIndex + 1 < program.macros().size()) { + final Macro nextMacro = program.macros().get(macroIndex + 1); + if (nextMacro.comment().isPresent() && nextMacro.comment().get().isInlineAfter(span)) { + final String inlinePart = getInlineCommentPart(nextMacro.comment().get(), span); + if (!inlinePart.isEmpty()) { + return Optional.of(inlinePart); + } + } + } + if (macroIndex == program.macros().size() - 1 && program.expression().comment().isPresent() + && program.expression().comment().get().isInlineAfter(span)) { + final String inlinePart = getInlineCommentPart(program.expression().comment().get(), span); + if (!inlinePart.isEmpty()) { + return Optional.of(inlinePart); + } + } + return Optional.empty(); + } + + private String formatExpression(final Expression expr, final boolean needsParens) { + return switch (expr) { + case AbstractionExpression abs -> formatAbstraction(abs, needsParens); + case ApplicationExpression app -> formatApplication(app, needsParens); + case IdentifierExpression id -> id.name(); + }; + } + + private String formatAbstraction(final AbstractionExpression abs, final boolean needsParens) { + final StringBuilder sb = new StringBuilder(); + + if (needsParens) { + sb.append("("); + } + switch (syntax) { + case LAMBDA -> { + sb.append("λ").append(abs.parameter()).append("."); + sb.append(formatExpression(abs.body(), false)); + } + case ARROW -> { + sb.append(abs.parameter()).append(" -> "); + sb.append(formatExpression(abs.body(), false)); + } + } + if (needsParens) { + sb.append(")"); + } + + return sb.toString(); + } + + private String formatApplication(final ApplicationExpression app, final boolean needsParens) { + return switch (syntax) { + case LAMBDA -> formatLambdaApplication(app, needsParens); + case ARROW -> formatArrowApplication(app); + }; + } + + private String formatLambdaApplication(final ApplicationExpression app, final boolean needsParens) { + final StringBuilder sb = new StringBuilder(); + + if (needsParens) { + sb.append("("); + } + + final boolean funcNeedsParens = app.applicable() instanceof AbstractionExpression; + sb.append(formatExpression(app.applicable(), funcNeedsParens)); + + sb.append(" "); + + final boolean argNeedsParens = app.argument() instanceof ApplicationExpression + || app.argument() instanceof AbstractionExpression; + sb.append(formatExpression(app.argument(), argNeedsParens)); + + if (needsParens) { + sb.append(")"); + } + + return sb.toString(); + } + + private String formatArrowApplication(final ApplicationExpression app) { + final StringBuilder sb = new StringBuilder(); + + Expression func = app.applicable(); + final List<Expression> args = new ArrayList<>(); + args.add(app.argument()); + + while (func instanceof ApplicationExpression appFunc) { + args.addFirst(appFunc.argument()); + func = appFunc.applicable(); + } + + final boolean funcNeedsParens = func instanceof AbstractionExpression; + if (funcNeedsParens) { + sb.append("("); + } + sb.append(formatExpression(func, false)); + if (funcNeedsParens) { + sb.append(")"); + } + + for (final Expression arg : args) { + sb.append("("); + sb.append(formatExpression(arg, false)); + sb.append(")"); + } + + return sb.toString(); + } + + /** + * Formats a program in the specified syntax. + */ + public static String emit(final LambdaProgram program, final Syntax syntax) { + return new Formatter(syntax).format(program); + } +} diff --git a/core/src/main/java/coffee/liz/lambda/parser/ArrowParser.jjt b/core/src/main/java/coffee/liz/lambda/parser/ArrowParser.jjt new file mode 100644 index 0000000..9ad9099 --- /dev/null +++ b/core/src/main/java/coffee/liz/lambda/parser/ArrowParser.jjt @@ -0,0 +1,276 @@ +options { + STATIC = false; + LOOKAHEAD = 2; + UNICODE_INPUT = true; + NODE_PREFIX = "AST"; +} + +PARSER_BEGIN(ArrowParser) +package coffee.liz.lambda.parser; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import coffee.liz.lambda.ast.Expression.AbstractionExpression; +import coffee.liz.lambda.ast.Expression.ApplicationExpression; +import coffee.liz.lambda.ast.Expression.IdentifierExpression; +import coffee.liz.lambda.ast.LambdaProgram; +import coffee.liz.lambda.ast.Macro; +import coffee.liz.lambda.ast.Expression; +import coffee.liz.lambda.ast.SourceSpan; +import coffee.liz.lambda.ast.SourceComment; + +public class ArrowParser { + public static void main(final String[] args) throws Exception { + final ArrowParser parser = new ArrowParser(System.in); + final LambdaProgram program = parser.Program(); + System.out.println(program); + } + + private static SourceSpan spanFrom(final Token start, final Token end) { + return new SourceSpan(start.beginLine, start.beginColumn, end.endLine, end.endColumn); + } + + private static SourceSpan spanFrom(final Token start, final Expression end) { + return new SourceSpan(start.beginLine, start.beginColumn, end.span().endLine(), end.span().endColumn()); + } + + private static SourceSpan spanFromInts(final int startLine, final int startColumn, final Token end) { + return new SourceSpan(startLine, startColumn, end.endLine, end.endColumn); + } + + private static SourceSpan spanFromInts(final int startLine, final int startColumn, final Expression end) { + return new SourceSpan(startLine, startColumn, end.span().endLine(), end.span().endColumn()); + } + + private static Expression withComment(final Expression expr, final Optional<SourceComment> comment) { + if (comment.isEmpty()) { + return expr; + } + if (expr instanceof AbstractionExpression) { + final AbstractionExpression a = (AbstractionExpression) expr; + return new AbstractionExpression(comment, a.span(), a.parameter(), a.body()); + } + if (expr instanceof ApplicationExpression) { + final ApplicationExpression a = (ApplicationExpression) expr; + return new ApplicationExpression(comment, a.span(), a.applicable(), a.argument()); + } + if (expr instanceof IdentifierExpression) { + final IdentifierExpression a = (IdentifierExpression) expr; + return new IdentifierExpression(comment, a.span(), a.name()); + } + return expr; + } + + private static Expression withSpan(final Expression expr, final SourceSpan span) { + if (expr instanceof AbstractionExpression) { + final AbstractionExpression a = (AbstractionExpression) expr; + return new AbstractionExpression(a.comment(), span, a.parameter(), a.body()); + } + if (expr instanceof ApplicationExpression) { + final ApplicationExpression a = (ApplicationExpression) expr; + return new ApplicationExpression(a.comment(), span, a.applicable(), a.argument()); + } + if (expr instanceof IdentifierExpression) { + final IdentifierExpression a = (IdentifierExpression) expr; + return new IdentifierExpression(a.comment(), span, a.name()); + } + return expr; + } + + private static Expression withCommentAndSpan(final Expression expr, final Optional<SourceComment> comment, final SourceSpan span) { + if (expr instanceof AbstractionExpression) { + final AbstractionExpression a = (AbstractionExpression) expr; + return new AbstractionExpression(comment, span, a.parameter(), a.body()); + } + if (expr instanceof ApplicationExpression) { + final ApplicationExpression a = (ApplicationExpression) expr; + return new ApplicationExpression(comment, span, a.applicable(), a.argument()); + } + if (expr instanceof IdentifierExpression) { + final IdentifierExpression a = (IdentifierExpression) expr; + return new IdentifierExpression(comment, span, a.name()); + } + return expr; + } + + private static Optional<SourceComment> extractComment(final Token t) { + if (t == null || t.specialToken == null) { + return Optional.empty(); + } + Token st = t.specialToken; + while (st.specialToken != null) { + st = st.specialToken; + } + final Token firstComment = st; + final StringBuilder sb = new StringBuilder(); + Token lastComment = st; + int lastEndLine = 0; + while (st != null) { + if (sb.length() > 0) { + final int blankLines = st.beginLine - lastEndLine - 1; + for (int i = 0; i < blankLines; i++) { + sb.append("\n"); + } + sb.append("\n"); + } + String image = st.image; + while (image.endsWith("\n") || image.endsWith("\r")) { + image = image.substring(0, image.length() - 1); + } + sb.append(image); + lastEndLine = st.endLine; + lastComment = st; + st = st.next; + } + final SourceSpan span = new SourceSpan( + firstComment.beginLine, firstComment.beginColumn, + lastComment.endLine, lastComment.endColumn + ); + return Optional.of(new SourceComment(sb.toString(), span)); + } +} +PARSER_END(ArrowParser) + +SKIP : { + " " | "\t" | "\r" | "\n" +} + +SPECIAL_TOKEN : { + < COMMENT: "--" (~["\n","\r"])* ("\n"|"\r"|"\r\n")? > +} + +TOKEN : { + < LET: "let" > + | < ARROW: "->" > + | < EQ: "=" > + | < SEMI: ";" > + | < LPAREN: "(" > + | < RPAREN: ")" > + | < IDENT: (["a"-"z","A"-"Z","0"-"9","_"])+ > +} + +LambdaProgram Program() : +{ + final List<Macro> macros = new ArrayList<Macro>(); + Macro m; + Expression body; + Token eofToken; + Optional<SourceComment> eofComment; +} +{ + ( + m = Macro() + { macros.add(m); } + )* + body = Expression() + eofToken = <EOF> + { eofComment = extractComment(eofToken); } + { + final SourceSpan span; + if (!macros.isEmpty()) { + span = spanFromInts(macros.get(0).span().startLine(), macros.get(0).span().startColumn(), eofToken); + } else { + span = new SourceSpan(body.span().startLine(), body.span().startColumn(), eofToken.endLine, eofToken.endColumn); + } + if (eofComment.isPresent() && body.comment().isEmpty()) { + body = withComment(body, eofComment); + } + return new LambdaProgram(span, macros, body); + } +} + +Macro Macro() : +{ + Token letToken; + Token name; + Token semiToken; + Expression value; + Optional<SourceComment> comment; +} +{ + letToken = <LET> + { comment = extractComment(letToken); } + name = <IDENT> + <EQ> + value = Expression() + semiToken = <SEMI> + { + return new Macro(comment, spanFrom(letToken, semiToken), name.image, value); + } +} + +Expression Expression() : +{ + Token paramToken; + Expression e; + Expression body; + Optional<SourceComment> comment; +} +{ + ( + LOOKAHEAD(<IDENT> <ARROW>) + paramToken = <IDENT> + { comment = extractComment(paramToken); } + <ARROW> + body = Expression() + { + e = new AbstractionExpression(comment, spanFrom(paramToken, body), paramToken.image, body); + } + | + e = Application() + ) + { + return e; + } +} + +Expression Application() : +{ + Expression e; + Expression arg; + Token rparen; +} +{ + e = Atom() + ( + <LPAREN> + arg = Expression() + rparen = <RPAREN> + { + e = new ApplicationExpression(Optional.empty(), spanFromInts(e.span().startLine(), e.span().startColumn(), rparen), e, arg); + } + )* + { + return e; + } +} + +Expression Atom() : +{ + Token id; + Token lparen; + Token rparen; + Expression e; + Optional<SourceComment> comment; +} +{ + id = <IDENT> + { comment = extractComment(id); } + { + return new IdentifierExpression(comment, spanFrom(id, id), id.image); + } +| + lparen = <LPAREN> + { comment = extractComment(lparen); } + e = Expression() + rparen = <RPAREN> + { + final SourceSpan parenSpan = spanFrom(lparen, rparen); + if (comment.isPresent() && e.comment().isEmpty()) { + return withCommentAndSpan(e, comment, parenSpan); + } + return withSpan(e, parenSpan); + } +} + diff --git a/core/src/main/java/coffee/liz/lambda/parser/LambdaParser.jjt b/core/src/main/java/coffee/liz/lambda/parser/LambdaParser.jjt new file mode 100644 index 0000000..2482960 --- /dev/null +++ b/core/src/main/java/coffee/liz/lambda/parser/LambdaParser.jjt @@ -0,0 +1,287 @@ +options { + STATIC = false; + LOOKAHEAD = 2; + UNICODE_INPUT = true; + NODE_PREFIX = "AST"; +} + +PARSER_BEGIN(LambdaParser) +package coffee.liz.lambda.parser; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import coffee.liz.lambda.ast.Expression.AbstractionExpression; +import coffee.liz.lambda.ast.Expression.ApplicationExpression; +import coffee.liz.lambda.ast.Expression.IdentifierExpression; +import coffee.liz.lambda.ast.LambdaProgram; +import coffee.liz.lambda.ast.Macro; +import coffee.liz.lambda.ast.Expression; +import coffee.liz.lambda.ast.SourceSpan; +import coffee.liz.lambda.ast.SourceComment; + +public class LambdaParser { + public static void main(final String[] args) throws Exception { + final LambdaParser parser = new LambdaParser(System.in); + final LambdaProgram program = parser.Program(); + System.out.println(program); + } + + private static SourceSpan spanFrom(final Token start, final Token end) { + return new SourceSpan(start.beginLine, start.beginColumn, end.endLine, end.endColumn); + } + + private static SourceSpan spanFrom(final Token start, final Expression end) { + return new SourceSpan(start.beginLine, start.beginColumn, end.span().endLine(), end.span().endColumn()); + } + + private static SourceSpan spanFromInts(final int startLine, final int startColumn, final Token end) { + return new SourceSpan(startLine, startColumn, end.endLine, end.endColumn); + } + + private static SourceSpan spanFromInts(final int startLine, final int startColumn, final Expression end) { + return new SourceSpan(startLine, startColumn, end.span().endLine(), end.span().endColumn()); + } + + private static Expression withComment(final Expression expr, final Optional<SourceComment> comment) { + if (comment.isEmpty()) { + return expr; + } + if (expr instanceof AbstractionExpression) { + final AbstractionExpression a = (AbstractionExpression) expr; + return new AbstractionExpression(comment, a.span(), a.parameter(), a.body()); + } + if (expr instanceof ApplicationExpression) { + final ApplicationExpression a = (ApplicationExpression) expr; + return new ApplicationExpression(comment, a.span(), a.applicable(), a.argument()); + } + if (expr instanceof IdentifierExpression) { + final IdentifierExpression a = (IdentifierExpression) expr; + return new IdentifierExpression(comment, a.span(), a.name()); + } + return expr; + } + + private static Expression withSpan(final Expression expr, final SourceSpan span) { + if (expr instanceof AbstractionExpression) { + final AbstractionExpression a = (AbstractionExpression) expr; + return new AbstractionExpression(a.comment(), span, a.parameter(), a.body()); + } + if (expr instanceof ApplicationExpression) { + final ApplicationExpression a = (ApplicationExpression) expr; + return new ApplicationExpression(a.comment(), span, a.applicable(), a.argument()); + } + if (expr instanceof IdentifierExpression) { + final IdentifierExpression a = (IdentifierExpression) expr; + return new IdentifierExpression(a.comment(), span, a.name()); + } + return expr; + } + + private static Expression withCommentAndSpan(final Expression expr, final Optional<SourceComment> comment, final SourceSpan span) { + if (expr instanceof AbstractionExpression) { + final AbstractionExpression a = (AbstractionExpression) expr; + return new AbstractionExpression(comment, span, a.parameter(), a.body()); + } + if (expr instanceof ApplicationExpression) { + final ApplicationExpression a = (ApplicationExpression) expr; + return new ApplicationExpression(comment, span, a.applicable(), a.argument()); + } + if (expr instanceof IdentifierExpression) { + final IdentifierExpression a = (IdentifierExpression) expr; + return new IdentifierExpression(comment, span, a.name()); + } + return expr; + } + + private static Optional<SourceComment> extractComment(final Token t) { + if (t == null || t.specialToken == null) { + return Optional.empty(); + } + Token st = t.specialToken; + while (st.specialToken != null) { + st = st.specialToken; + } + final Token firstComment = st; + final StringBuilder sb = new StringBuilder(); + Token lastComment = st; + int lastEndLine = 0; + while (st != null) { + if (sb.length() > 0) { + final int blankLines = st.beginLine - lastEndLine - 1; + for (int i = 0; i < blankLines; i++) { + sb.append("\n"); + } + sb.append("\n"); + } + String image = st.image; + while (image.endsWith("\n") || image.endsWith("\r")) { + image = image.substring(0, image.length() - 1); + } + sb.append(image); + lastEndLine = st.endLine; + lastComment = st; + st = st.next; + } + final SourceSpan span = new SourceSpan( + firstComment.beginLine, firstComment.beginColumn, + lastComment.endLine, lastComment.endColumn + ); + return Optional.of(new SourceComment(sb.toString(), span)); + } +} +PARSER_END(LambdaParser) + +SKIP : { + " " | "\t" | "\r" | "\n" +} + +SPECIAL_TOKEN : { + < COMMENT: "--" (~["\n","\r"])* ("\n"|"\r"|"\r\n")? > +} + +TOKEN : { + < LET: "let" > + | < LAMBDA: "λ" | "\\" > + | < DOT: "." > + | < EQ: "=" > + | < SEMI: ";" > + | < LPAREN: "(" > + | < RPAREN: ")" > + | < IDENT: (["a"-"z","A"-"Z","0"-"9","_"])+ > +} + +LambdaProgram Program() : +{ + final List<Macro> macros = new ArrayList<Macro>(); + Macro m; + Expression body; + Token eofToken; + Optional<SourceComment> eofComment; +} +{ + ( + m = Macro() + { macros.add(m); } + )* + body = Expression() + eofToken = <EOF> + { eofComment = extractComment(eofToken); } + { + final SourceSpan span; + if (!macros.isEmpty()) { + span = spanFromInts(macros.get(0).span().startLine(), macros.get(0).span().startColumn(), eofToken); + } else { + span = new SourceSpan(body.span().startLine(), body.span().startColumn(), eofToken.endLine, eofToken.endColumn); + } + if (eofComment.isPresent() && body.comment().isEmpty()) { + body = withComment(body, eofComment); + } + return new LambdaProgram(span, macros, body); + } +} + +Macro Macro() : +{ + Token letToken; + Token name; + Token semiToken; + Expression value; + Optional<SourceComment> comment; +} +{ + letToken = <LET> + { comment = extractComment(letToken); } + name = <IDENT> + <EQ> + value = Expression() + semiToken = <SEMI> + { + return new Macro(comment, spanFrom(letToken, semiToken), name.image, value); + } +} + +Expression Expression() : +{ + Expression e; +} +{ + ( + e = Lambda() + | + e = Application() + ) + { + return e; + } +} + +Expression Lambda() : +{ + Token lambdaToken; + Token param; + Expression body; + Optional<SourceComment> comment; +} +{ + lambdaToken = <LAMBDA> + { comment = extractComment(lambdaToken); } + param = <IDENT> + <DOT> + body = Expression() + { + return new AbstractionExpression(comment, spanFrom(lambdaToken, body), param.image, body); + } +} + +Expression Application() : +{ + Expression e; + Expression arg; +} +{ + e = Atom() + ( + arg = Atom() + { + e = new ApplicationExpression(Optional.empty(), spanFromInts(e.span().startLine(), e.span().startColumn(), arg), e, arg); + } + )* + { + return e; + } +} + +Expression Atom() : +{ + Token id; + Token lparen; + Token rparen; + Expression e; + Optional<SourceComment> comment; +} +{ + id = <IDENT> + { comment = extractComment(id); } + { + return new IdentifierExpression(comment, spanFrom(id, id), id.image); + } +| + lparen = <LPAREN> + { comment = extractComment(lparen); } + e = Expression() + rparen = <RPAREN> + { + final SourceSpan parenSpan = spanFrom(lparen, rparen); + if (comment.isPresent() && e.comment().isEmpty()) { + return withCommentAndSpan(e, comment, parenSpan); + } + return withSpan(e, parenSpan); + } +| + e = Lambda() + { + return e; + } +} + diff --git a/core/src/main/resources/log4j2.xml b/core/src/main/resources/log4j2.xml new file mode 100644 index 0000000..c52aaea --- /dev/null +++ b/core/src/main/resources/log4j2.xml @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="UTF-8"?> +<Configuration status="WARN"> + <Appenders> + <Console name="Console" target="SYSTEM_OUT"> + <PatternLayout pattern="%d{HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n"/> + </Console> + </Appenders> + <Loggers> + <Root level="info"> + <AppenderRef ref="Console"/> + </Root> + </Loggers> +</Configuration> |
