summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--build.xml145
-rw-r--r--ivy.xml28
-rw-r--r--src/moe/anita/mai/buffer/Buffer.java43
-rw-r--r--src/moe/anita/mai/buffer/MemBuf.java29
-rw-r--r--src/moe/anita/mai/buffer/SrcBuf.java44
-rw-r--r--src/moe/anita/mai/lexer/Lexer.java332
-rw-r--r--test/moe/anita/mai/buffer/MemBufTest.java51
-rw-r--r--test/moe/anita/mai/buffer/SrcBufTest.java72
-rw-r--r--test/moe/anita/mai/lexer/LexerNumberTest.java84
-rw-r--r--test/moe/anita/mai/lexer/LexerTest.java101
-rw-r--r--test/moe/anita/mai/test/Case.java26
-rw-r--r--test/moe/anita/mai/test/Runner.java21
-rw-r--r--test/moe/anita/mai/test/TestRunner.java209
13 files changed, 1050 insertions, 135 deletions
diff --git a/build.xml b/build.xml
index f5d65bc..0e2400a 100644
--- a/build.xml
+++ b/build.xml
@@ -1,12 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
-<project
- name="mai"
- default="build"
- basedir="."
- xmlns:ivy="antlib:org.apache.ivy.ant">
+<project name="mai" default="build" basedir=".">
<!-- Project -->
+
<property name="src.dir" value="${basedir}/src"/>
<property name="test.dir" value="${basedir}/test"/>
@@ -15,57 +12,23 @@
<property name="test.classes.dir" value="${build.dir}/test-classes"/>
<property name="test.results.dir" value="${build.dir}/test-results"/>
<property name="dist.dir" value="${build.dir}/dist"/>
- <property name="deps.dir" value="${build.dir}/deps"/>
-
- <!-- Bootstrap tools -->
- <property name="tools.dir" value="${basedir}/.tools"/>
- <!-- Java -->
- <property name="java.release" value="25"/>
+ <!-- Entry points -->
- <!-- Ivy -->
- <property name="ivy.version" value="2.6.0"/>
- <property
- name="ivy.jar"
- value="${tools.dir}/ivy-${ivy.version}.jar"/>
-
- <property
- name="ivy.url"
- value="https://repo.maven.apache.org/maven2/org/apache/ivy/ivy/${ivy.version}/ivy-${ivy.version}.jar"/>
-
- <!-- Application -->
<property name="main.class" value="moe.anita.mai.Main"/>
- <property name="jar.file" value="${dist.dir}/mai.jar"/>
- <!-- Ivy bootstrap -->
+ <property name="test.main.class" value="moe.anita.mai.test.TestRunner"/>
- <target name="check-ivy">
- <available
- file="${ivy.jar}"
- property="ivy.available"/>
- </target>
+ <property name="jar.file" value="${dist.dir}/mai.jar"/>
- <target
- name="download-ivy"
- depends="check-ivy"
- unless="ivy.available">
+ <!-- Bootstrap tools -->
- <mkdir dir="${tools.dir}"/>
+ <property name="tools.dir" value="${basedir}/.tools"/>
- <echo message="Downloading Apache Ivy ${ivy.version}..."/>
+ <!-- Java -->
- <get
- src="${ivy.url}"
- dest="${ivy.jar}"
- usetimestamp="true"/>
- </target>
+ <property name="java.release" value="25"/>
- <target name="bootstrap-ivy" depends="download-ivy">
- <taskdef
- resource="org/apache/ivy/ant/antlib.xml"
- uri="antlib:org.apache.ivy.ant"
- classpath="${ivy.jar}"/>
- </target>
<!-- Initialization -->
@@ -74,26 +37,8 @@
<mkdir dir="${test.classes.dir}"/>
<mkdir dir="${test.results.dir}"/>
<mkdir dir="${dist.dir}"/>
- <mkdir dir="${deps.dir}"/>
- </target>
-
- <!-- Dependencies -->
-
- <target name="resolve" depends="init,bootstrap-ivy">
- <ivy:resolve file="${basedir}/ivy.xml"/>
-
- <ivy:retrieve
- conf="test"
- pattern="${deps.dir}/test/[artifact]-[revision].[ext]"/>
</target>
- <path id="test.classpath">
- <pathelement location="${classes.dir}"/>
-
- <fileset dir="${deps.dir}/test">
- <include name="*.jar"/>
- </fileset>
- </path>
<!-- Compile -->
@@ -107,7 +52,7 @@
debug="true"/>
</target>
- <target name="compile-test" depends="compile,resolve">
+ <target name="compile-test" depends="compile">
<javac
srcdir="${test.dir}"
destdir="${test.classes.dir}"
@@ -116,36 +61,37 @@
includeantruntime="false"
debug="true">
- <classpath refid="test.classpath"/>
+ <classpath>
+ <pathelement location="${classes.dir}"/>
+ </classpath>
</javac>
</target>
- <!-- Tests -->
+
+ <!-- Test -->
<target name="test" depends="compile-test">
- <junitlauncher
- haltOnFailure="true"
- printSummary="true">
+ <java
+ classname="${test.main.class}"
+ fork="true"
+ failonerror="true">
<classpath>
<pathelement location="${classes.dir}"/>
<pathelement location="${test.classes.dir}"/>
-
- <fileset dir="${deps.dir}/test">
- <include name="*.jar"/>
- </fileset>
</classpath>
- <testclasses outputdir="${test.results.dir}">
- <fileset dir="${test.classes.dir}">
- <include name="**/*Test.class"/>
- </fileset>
+ <!-- Enable Java assert statements. -->
+ <jvmarg value="-ea"/>
- <listener type="legacy-plain"/>
- </testclasses>
- </junitlauncher>
+ <!-- TestRunner scans this directory for @Runner classes. -->
+ <sysproperty
+ key="mai.test.classes"
+ value="${test.classes.dir}"/>
+ </java>
</target>
+
<!-- JAR -->
<target name="jar" depends="compile">
@@ -164,12 +110,14 @@
</jar>
</target>
+
<!-- Build -->
<target name="build" depends="jar">
<echo message="Built ${jar.file}"/>
</target>
+
<!-- Run -->
<target name="run" depends="compile">
@@ -184,9 +132,10 @@
</java>
</target>
+
<!-- LSP / JDT LS -->
- <target name="lsp" depends="resolve">
+ <target name="lsp">
<echo message="Generating JDT LS project files..."/>
<echo file="${basedir}/.project"><![CDATA[<?xml version="1.0" encoding="UTF-8"?>
@@ -213,37 +162,16 @@
<classpathentry
kind="con"
path="org.eclipse.jdt.launching.JRE_CONTAINER"/>
-]]></echo>
-
- <fileset id="lsp.dependencies" dir="${deps.dir}/test">
- <include name="*.jar"/>
- </fileset>
-
- <pathconvert
- property="lsp.dependency.entries"
- refid="lsp.dependencies"
- pathsep="&#10;">
-
- <mapper>
- <globmapper
- from="${basedir}/*"
- to=" &lt;classpathentry kind=&quot;lib&quot; path=&quot;*&quot;/&gt;"/>
- </mapper>
- </pathconvert>
-
- <echo
- file="${basedir}/.classpath"
- append="true">${lsp.dependency.entries}
-</echo>
-
- <echo file="${basedir}/.classpath" append="true"><![CDATA[
- <classpathentry kind="output" path="build/classes"/>
+ <classpathentry
+ kind="output"
+ path="build/classes"/>
</classpath>
]]></echo>
<echo message="Generated .project and .classpath"/>
</target>
+
<!-- Clean -->
<target name="clean">
@@ -260,6 +188,9 @@
<delete dir="${tools.dir}"/>
</target>
+
+ <!-- Rebuild -->
+
<target name="rebuild" depends="clean,build"/>
</project>
diff --git a/ivy.xml b/ivy.xml
deleted file mode 100644
index 4f3d721..0000000
--- a/ivy.xml
+++ /dev/null
@@ -1,28 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-
-<ivy-module version="2.0">
-
- <info
- organisation="moe.anita"
- module="mai"/>
-
- <configurations>
- <conf
- name="compile"
- description="Compile-time dependencies"/>
-
- <conf
- name="test"
- extends="compile"
- description="Test dependencies"/>
- </configurations>
-
- <dependencies>
- <dependency
- org="org.junit.jupiter"
- name="junit-jupiter"
- rev="5.13.4"
- conf="test->default"/>
- </dependencies>
-
-</ivy-module>
diff --git a/src/moe/anita/mai/buffer/Buffer.java b/src/moe/anita/mai/buffer/Buffer.java
new file mode 100644
index 0000000..c235bbb
--- /dev/null
+++ b/src/moe/anita/mai/buffer/Buffer.java
@@ -0,0 +1,43 @@
+package moe.anita.mai.buffer;
+
+public interface Buffer {
+
+ /**
+ * Returns the number of characters in the buffer.
+ */
+ int length();
+
+ /**
+ * Returns the character at the given offset.
+ *
+ * @param index character offset
+ * @return character at the offset
+ */
+ char get(int index);
+
+ /**
+ * Returns whether the given offset exists in the buffer.
+ *
+ * @param index character offset
+ * @return true when the offset is valid
+ */
+ default boolean has(int index) {
+ return index >= 0 && index < length();
+ }
+
+ /**
+ * Returns whether the buffer is empty.
+ */
+ default boolean isEmpty() {
+ return length() == 0;
+ }
+
+ /**
+ * Returns a slice of the buffer in the range [start, end).
+ *
+ * @param start starting offset
+ * @param end ending offset
+ * @return characters in the requested range
+ */
+ CharSequence slice(int start, int end);
+}
diff --git a/src/moe/anita/mai/buffer/MemBuf.java b/src/moe/anita/mai/buffer/MemBuf.java
new file mode 100644
index 0000000..a2bc477
--- /dev/null
+++ b/src/moe/anita/mai/buffer/MemBuf.java
@@ -0,0 +1,29 @@
+package moe.anita.mai.buffer;
+
+import java.util.Objects;
+
+/**
+ * Buffer backed entirely by an in-memory string.
+ */
+public final class MemBuf implements Buffer {
+
+ private final String data;
+
+ public MemBuf(String data) { this.data = Objects.requireNonNull(data);
+ }
+
+ @Override
+ public int length() {
+ return data.length();
+ }
+
+ @Override
+ public char get(int index) {
+ return data.charAt(index);
+ }
+
+ @Override
+ public CharSequence slice(int start, int end) {
+ return data.subSequence(start, end);
+ }
+}
diff --git a/src/moe/anita/mai/buffer/SrcBuf.java b/src/moe/anita/mai/buffer/SrcBuf.java
new file mode 100644
index 0000000..670bcbe
--- /dev/null
+++ b/src/moe/anita/mai/buffer/SrcBuf.java
@@ -0,0 +1,44 @@
+package moe.anita.mai.buffer;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Objects;
+
+/**
+ * A buffer backed by the contents of a source file.
+ */
+public final class SrcBuf implements Buffer {
+
+ private final Path path;
+ private final String data;
+
+ public SrcBuf(Path path) throws IOException {
+ this.path = Objects.requireNonNull(path);
+ this.data = Files.readString(path);
+ }
+
+ /**
+ * Returns the path of the source file.
+ *
+ * @return source file path
+ */
+ public Path path() {
+ return path;
+ }
+
+ @Override
+ public int length() {
+ return data.length();
+ }
+
+ @Override
+ public char get(int index) {
+ return data.charAt(index);
+ }
+
+ @Override
+ public CharSequence slice(int start, int end) {
+ return data.subSequence(start, end);
+ }
+}
diff --git a/src/moe/anita/mai/lexer/Lexer.java b/src/moe/anita/mai/lexer/Lexer.java
new file mode 100644
index 0000000..56f4534
--- /dev/null
+++ b/src/moe/anita/mai/lexer/Lexer.java
@@ -0,0 +1,332 @@
+package moe.anita.mai.lexer;
+
+import moe.anita.mai.buffer.Buffer;
+import moe.anita.mai.span.Pos;
+import moe.anita.mai.span.Span;
+import moe.anita.mai.token.Token;
+import moe.anita.mai.token.TokenKind;
+
+public final class Lexer {
+
+ private final Buffer buffer;
+
+ private int offset;
+ private int line;
+ private int column;
+
+ public Lexer(Buffer buffer) {
+ this.buffer = buffer;
+
+ offset = 0;
+ line = 1;
+ column = 1;
+ }
+
+ /**
+ * Returns the next token in the source.
+ *
+ * @return next token
+ */
+ public Token next() {
+ skipWhitespace();
+
+ Pos start = pos();
+
+ if (eof()) {
+ return token(TokenKind.EOF, start);
+ }
+
+ char c = current();
+
+ if (c == '\n') {
+ advance();
+ return token(TokenKind.EOL, start);
+ }
+
+ if (isDigit(c)) {
+ return number(start);
+ }
+
+ if (isIdentStart(c)) {
+ return ident(start);
+ }
+
+ advance();
+
+ return switch (c) {
+ case '(' -> token(TokenKind.LPAREN, start);
+ case ')' -> token(TokenKind.RPAREN, start);
+
+ case '[' -> token(TokenKind.LBRACKET, start);
+ case ']' -> token(TokenKind.RBRACKET, start);
+
+ case '{' -> token(TokenKind.LBRACE, start);
+ case '}' -> token(TokenKind.RBRACE, start);
+
+ case ',' -> token(TokenKind.COMMA, start);
+ case '.' -> token(TokenKind.DOT, start);
+ case ';' -> token(TokenKind.SEMICOLON, start);
+ case '?' -> token(TokenKind.QUESTION, start);
+ case '@' -> token(TokenKind.AT, start);
+
+ case '+' -> {
+ if (match('+')) {
+ yield token(TokenKind.INC, start);
+ }
+
+ yield token(TokenKind.ADD, start);
+ }
+
+ case '-' -> {
+ if (match('-')) {
+ yield token(TokenKind.DEC, start);
+ }
+
+ if (match('>')) {
+ yield token(TokenKind.ARROW, start);
+ }
+
+ yield token(TokenKind.SUB, start);
+ }
+
+ case '*' -> token(TokenKind.MUL, start);
+ case '/' -> token(TokenKind.DIV, start);
+ case '%' -> token(TokenKind.MOD, start);
+
+ case '=' -> {
+ if (match('=')) {
+ yield token(TokenKind.EQ, start);
+ }
+
+ yield token(TokenKind.ASSIGN, start);
+ }
+
+ case ':' -> {
+ if (match('=')) {
+ yield token(TokenKind.DECLARE, start);
+ }
+
+ yield token(TokenKind.COLON, start);
+ }
+
+ case '<' -> {
+ if (match('=')) {
+ yield token(TokenKind.LE, start);
+ }
+
+ if (match('>')) {
+ yield token(TokenKind.NE, start);
+ }
+
+ if (match('-')) {
+ yield token(TokenKind.BIND, start);
+ }
+
+ yield token(TokenKind.LT, start);
+ }
+
+ case '>' -> {
+ if (match('=')) {
+ yield token(TokenKind.GE, start);
+ }
+
+ yield token(TokenKind.GT, start);
+ }
+
+ case '&' -> {
+ if (match('&')) {
+ yield token(TokenKind.AND, start);
+ }
+
+ yield token(TokenKind.BIT_AND, start);
+ }
+
+ case '|' -> {
+ if (match('|')) {
+ yield token(TokenKind.OR, start);
+ }
+
+ yield token(TokenKind.BIT_OR, start);
+ }
+
+ case '^' -> token(TokenKind.BIT_XOR, start);
+ case '~' -> token(TokenKind.BIT_NOT, start);
+ case '!' -> token(TokenKind.NOT, start);
+
+ default -> token(TokenKind.ERROR, start);
+ };
+ }
+
+ private Token number(Pos start) {
+ if (current() == '0' && has(1)) {
+ char prefix = peek(1);
+
+ switch (prefix) {
+ case 'x', 'X' -> {
+ advance();
+ advance();
+
+ while (!eof() && isHexDigitOrUnderscore(current())) {
+ advance();
+ }
+
+ return token(TokenKind.INT, start);
+ }
+
+ case 'b', 'B' -> {
+ advance();
+ advance();
+
+ while (!eof() && isBinaryDigitOrUnderscore(current())) {
+ advance();
+ }
+
+ return token(TokenKind.INT, start);
+ }
+
+ case 'o', 'O' -> {
+ advance();
+ advance();
+
+ while (!eof() && isOctalDigitOrUnderscore(current())) {
+ advance();
+ }
+
+ return token(TokenKind.INT, start);
+ }
+ }
+ }
+
+ while (!eof() && isDigitOrUnderscore(current())) {
+ advance();
+ }
+
+ if (
+ !eof()
+ && current() == '.'
+ && has(1)
+ && isDigit(peek(1))
+ ) {
+ advance();
+
+ while (!eof() && isDigitOrUnderscore(current())) {
+ advance();
+ }
+
+ return token(TokenKind.FLOAT, start);
+ }
+
+ return token(TokenKind.INT, start);
+ }
+
+ private Token ident(Pos start) {
+ while (!eof() && isIdentContinue(current())) {
+ advance();
+ }
+
+ return token(TokenKind.IDENT, start);
+ }
+
+ private void skipWhitespace() {
+ while (!eof()) {
+ char c = current();
+
+ if (
+ c != ' '
+ && c != '\t'
+ && c != '\r'
+ ) {
+ return;
+ }
+
+ advance();
+ }
+ }
+
+ private boolean isDigit(char c) {
+ return c >= '0' && c <= '9';
+ }
+
+ private boolean isDigitOrUnderscore(char c) {
+ return isDigit(c) || c == '_';
+ }
+
+ private boolean isHexDigitOrUnderscore(char c) {
+ return isDigit(c)
+ || (c >= 'a' && c <= 'f')
+ || (c >= 'A' && c <= 'F')
+ || c == '_';
+ }
+
+ private boolean isBinaryDigitOrUnderscore(char c) {
+ return c == '0'
+ || c == '1'
+ || c == '_';
+ }
+
+ private boolean isOctalDigitOrUnderscore(char c) {
+ return (c >= '0' && c <= '7')
+ || c == '_';
+ }
+
+ private boolean isIdentStart(char c) {
+ return c == '_'
+ || Character.isUnicodeIdentifierStart(c);
+ }
+
+ private boolean isIdentContinue(char c) {
+ return c == '_'
+ || Character.isUnicodeIdentifierPart(c);
+ }
+
+ private boolean eof() {
+ return offset >= buffer.length();
+ }
+
+ private boolean has(int distance) {
+ return offset + distance < buffer.length();
+ }
+
+ private char current() {
+ return buffer.get(offset);
+ }
+
+ private char peek(int distance) {
+ return buffer.get(offset + distance);
+ }
+
+ private boolean match(char expected) {
+ if (eof() || current() != expected) {
+ return false;
+ }
+
+ advance();
+ return true;
+ }
+
+ private char advance() {
+ char c = current();
+
+ offset++;
+
+ if (c == '\n') {
+ line++;
+ column = 1;
+ } else {
+ column++;
+ }
+
+ return c;
+ }
+
+ private Pos pos() {
+ return Pos.of(offset, line, column);
+ }
+
+ private Token token(TokenKind kind, Pos start) {
+ return new Token(
+ kind,
+ Span.of(start, pos())
+ );
+ }
+}
diff --git a/test/moe/anita/mai/buffer/MemBufTest.java b/test/moe/anita/mai/buffer/MemBufTest.java
new file mode 100644
index 0000000..450ac31
--- /dev/null
+++ b/test/moe/anita/mai/buffer/MemBufTest.java
@@ -0,0 +1,51 @@
+package moe.anita.mai.buffer;
+
+import moe.anita.mai.test.Case;
+import moe.anita.mai.test.Runner;
+
+@Runner(name = "MemBuf")
+public final class MemBufTest {
+
+ @Case(name = "length")
+ static void length() {
+ var buffer = new MemBuf("mai");
+
+ assert buffer.length() == 3;
+ }
+
+ @Case(name = "read")
+ static void read() {
+ var buffer = new MemBuf("mai");
+
+ assert buffer.get(0) == 'm';
+ assert buffer.get(1) == 'a';
+ assert buffer.get(2) == 'i';
+ }
+
+ @Case(name = "bounds")
+ static void bounds() {
+ var buffer = new MemBuf("mai");
+
+ assert !buffer.has(-1);
+ assert buffer.has(0);
+ assert buffer.has(2);
+ assert !buffer.has(3);
+ }
+
+ @Case(name = "empty")
+ static void empty() {
+ assert new MemBuf("").isEmpty();
+ assert !new MemBuf("mai").isEmpty();
+ }
+
+ @Case(name = "slice")
+ static void slice() {
+ var buffer = new MemBuf("hello mai");
+
+ assert buffer.slice(0, 5).toString().equals("hello");
+ assert buffer.slice(6, 9).toString().equals("mai");
+ }
+
+ private MemBufTest() {
+ }
+}
diff --git a/test/moe/anita/mai/buffer/SrcBufTest.java b/test/moe/anita/mai/buffer/SrcBufTest.java
new file mode 100644
index 0000000..ced5d1c
--- /dev/null
+++ b/test/moe/anita/mai/buffer/SrcBufTest.java
@@ -0,0 +1,72 @@
+package moe.anita.mai.buffer;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import moe.anita.mai.buffer.SrcBuf;
+
+import moe.anita.mai.test.Case;
+import moe.anita.mai.test.Runner;
+
+@Runner(name = "SrcBuf")
+public final class SrcBufTest {
+
+ @Case(name = "read test")
+ static void read() throws IOException {
+ Path path = Files.createTempFile("mai-src-", ".mai");
+
+ try {
+ Files.writeString(path, "mai");
+
+ var buffer = new SrcBuf(path);
+
+ assert buffer.length() == 3;
+
+ assert buffer.get(0) == 'm';
+ assert buffer.get(1) == 'a';
+ assert buffer.get(2) == 'i';
+ } finally {
+ Files.deleteIfExists(path);
+ }
+ }
+
+ @Case(name = "slices test")
+ static void slice() throws IOException {
+ Path path = Files.createTempFile("mai-src-",".mai");
+
+ try {
+ Files.writeString(path, "hello mai");
+
+ var buffer = new SrcBuf(path);
+
+ assert buffer
+ .slice(0, 5)
+ .toString()
+ .equals("hello");
+
+ assert buffer
+ .slice(6, 9)
+ .toString()
+ .equals("mai");
+ } finally {
+ Files.deleteIfExists(path);
+ }
+ }
+
+ @Case(name = "retains source path")
+ static void path() throws IOException {
+ Path path = Files.createTempFile("mai-src-", ".mai");
+
+ try {
+ var buffer = new SrcBuf(path);
+
+ assert buffer.path().equals(path);
+ } finally {
+ Files.deleteIfExists(path);
+ }
+ }
+
+ private SrcBufTest() {
+ }
+}
diff --git a/test/moe/anita/mai/lexer/LexerNumberTest.java b/test/moe/anita/mai/lexer/LexerNumberTest.java
new file mode 100644
index 0000000..1f4ab1f
--- /dev/null
+++ b/test/moe/anita/mai/lexer/LexerNumberTest.java
@@ -0,0 +1,84 @@
+package moe.anita.mai.lexer;
+
+import moe.anita.mai.buffer.MemBuf;
+import moe.anita.mai.test.Case;
+import moe.anita.mai.test.Runner;
+import moe.anita.mai.token.TokenKind;
+
+@Runner(name = "Lexer Numbers")
+public final class LexerNumberTest {
+
+ @Case(name = "integer literal")
+ static void integer() {
+ var lexer = lexer("12345");
+
+ assert lexer.next().kind() == TokenKind.INT;
+ assert lexer.next().kind() == TokenKind.EOF;
+ }
+
+ @Case(name = "integer underscores")
+ static void integerUnderscores() {
+ var lexer = lexer("1_000_000");
+
+ assert lexer.next().kind() == TokenKind.INT;
+ assert lexer.next().kind() == TokenKind.EOF;
+ }
+
+ @Case(name = "hexadecimal integer")
+ static void hexadecimal() {
+ var lexer = lexer("0xDEAD_BEEF");
+
+ assert lexer.next().kind() == TokenKind.INT;
+ assert lexer.next().kind() == TokenKind.EOF;
+ }
+
+ @Case(name = "binary integer")
+ static void binary() {
+ var lexer = lexer("0b1010_1100");
+
+ assert lexer.next().kind() == TokenKind.INT;
+ assert lexer.next().kind() == TokenKind.EOF;
+ }
+
+ @Case(name = "octal integer")
+ static void octal() {
+ var lexer = lexer("0o755");
+
+ assert lexer.next().kind() == TokenKind.INT;
+ assert lexer.next().kind() == TokenKind.EOF;
+ }
+
+ @Case(name = "float literal")
+ static void floatingPoint() {
+ var lexer = lexer("123.456");
+
+ assert lexer.next().kind() == TokenKind.FLOAT;
+ assert lexer.next().kind() == TokenKind.EOF;
+ }
+
+ @Case(name = "float underscores")
+ static void floatingPointUnderscores() {
+ var lexer = lexer("1_000.25_50");
+
+ assert lexer.next().kind() == TokenKind.FLOAT;
+ assert lexer.next().kind() == TokenKind.EOF;
+ }
+
+ @Case(name = "range is not a float")
+ static void range() {
+ var lexer = lexer("10..20");
+
+ assert lexer.next().kind() == TokenKind.INT;
+ assert lexer.next().kind() == TokenKind.DOT;
+ assert lexer.next().kind() == TokenKind.DOT;
+ assert lexer.next().kind() == TokenKind.INT;
+ assert lexer.next().kind() == TokenKind.EOF;
+ }
+
+ private static Lexer lexer(String source) {
+ return new Lexer(new MemBuf(source));
+ }
+
+ private LexerNumberTest() {
+ }
+}
diff --git a/test/moe/anita/mai/lexer/LexerTest.java b/test/moe/anita/mai/lexer/LexerTest.java
new file mode 100644
index 0000000..9ce8bef
--- /dev/null
+++ b/test/moe/anita/mai/lexer/LexerTest.java
@@ -0,0 +1,101 @@
+package moe.anita.mai.lexer;
+
+import moe.anita.mai.buffer.MemBuf;
+import moe.anita.mai.test.Case;
+import moe.anita.mai.test.Runner;
+import moe.anita.mai.token.TokenKind;
+
+@Runner(name = "Lexer")
+public final class LexerTest {
+
+ @Case(name = "empty source")
+ static void empty() {
+ var lexer = lexer("");
+
+ assert lexer.next().kind() == TokenKind.EOF;
+ }
+
+ @Case(name = "identifier")
+ static void identifier() {
+ var lexer = lexer("hello");
+
+ assert lexer.next().kind() == TokenKind.IDENT;
+ assert lexer.next().kind() == TokenKind.EOF;
+ }
+
+ @Case(name = "Unicode identifier")
+ static void unicodeIdentifier() {
+ var lexer = lexer("こんにちは");
+
+ assert lexer.next().kind() == TokenKind.IDENT;
+ assert lexer.next().kind() == TokenKind.EOF;
+ }
+
+ @Case(name = "newline")
+ static void newline() {
+ var lexer = lexer("\n");
+
+ assert lexer.next().kind() == TokenKind.EOL;
+ assert lexer.next().kind() == TokenKind.EOF;
+ }
+
+ @Case(name = "operators")
+ static void operators() {
+ var lexer = lexer(
+ "+ ++ - -- * / % == = := < <= <> > >= && & || | ^ ~ !"
+ );
+
+ assert lexer.next().kind() == TokenKind.ADD;
+ assert lexer.next().kind() == TokenKind.INC;
+
+ assert lexer.next().kind() == TokenKind.SUB;
+ assert lexer.next().kind() == TokenKind.DEC;
+
+ assert lexer.next().kind() == TokenKind.MUL;
+ assert lexer.next().kind() == TokenKind.DIV;
+ assert lexer.next().kind() == TokenKind.MOD;
+
+ assert lexer.next().kind() == TokenKind.EQ;
+ assert lexer.next().kind() == TokenKind.ASSIGN;
+ assert lexer.next().kind() == TokenKind.DECLARE;
+
+ assert lexer.next().kind() == TokenKind.LT;
+ assert lexer.next().kind() == TokenKind.LE;
+ assert lexer.next().kind() == TokenKind.NE;
+
+ assert lexer.next().kind() == TokenKind.GT;
+ assert lexer.next().kind() == TokenKind.GE;
+
+ assert lexer.next().kind() == TokenKind.AND;
+ assert lexer.next().kind() == TokenKind.BIT_AND;
+
+ assert lexer.next().kind() == TokenKind.OR;
+ assert lexer.next().kind() == TokenKind.BIT_OR;
+
+ assert lexer.next().kind() == TokenKind.BIT_XOR;
+ assert lexer.next().kind() == TokenKind.BIT_NOT;
+ assert lexer.next().kind() == TokenKind.NOT;
+
+ assert lexer.next().kind() == TokenKind.EOF;
+ }
+
+ @Case(name = "shift operators remain separate")
+ static void shiftsRemainSeparate() {
+ var lexer = lexer("<< >>");
+
+ assert lexer.next().kind() == TokenKind.LT;
+ assert lexer.next().kind() == TokenKind.LT;
+
+ assert lexer.next().kind() == TokenKind.GT;
+ assert lexer.next().kind() == TokenKind.GT;
+
+ assert lexer.next().kind() == TokenKind.EOF;
+ }
+
+ private static Lexer lexer(String source) {
+ return new Lexer(new MemBuf(source));
+ }
+
+ private LexerTest() {
+ }
+}
diff --git a/test/moe/anita/mai/test/Case.java b/test/moe/anita/mai/test/Case.java
new file mode 100644
index 0000000..1288a70
--- /dev/null
+++ b/test/moe/anita/mai/test/Case.java
@@ -0,0 +1,26 @@
+package moe.anita.mai.test;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Marks a method as a test case.
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.METHOD)
+public @interface Case {
+
+ /**
+ * Display name of the test case.
+ *
+ * If empty, the method name is used.
+ */
+ String name() default "";
+
+ /**
+ * Controls whether this test case is executed.
+ */
+ boolean enabled() default true;
+}
diff --git a/test/moe/anita/mai/test/Runner.java b/test/moe/anita/mai/test/Runner.java
new file mode 100644
index 0000000..c7c18c0
--- /dev/null
+++ b/test/moe/anita/mai/test/Runner.java
@@ -0,0 +1,21 @@
+package moe.anita.mai.test;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Marks a class as a test runner.
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.TYPE)
+public @interface Runner {
+
+ /**
+ * Display name of the runner.
+ *
+ * If empty, the class name is used.
+ */
+ String name() default "";
+}
diff --git a/test/moe/anita/mai/test/TestRunner.java b/test/moe/anita/mai/test/TestRunner.java
new file mode 100644
index 0000000..7c74bf3
--- /dev/null
+++ b/test/moe/anita/mai/test/TestRunner.java
@@ -0,0 +1,209 @@
+package moe.anita.mai.test;
+
+import java.io.File;
+import java.io.IOException;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+public final class TestRunner {
+
+ public static void main(String[] args) throws IOException {
+ Path root = Path.of(
+ System.getProperty(
+ "mai.test.classes",
+ "build/test-classes"
+ )
+ );
+
+ Result result = new Result();
+
+ try (var paths = Files.walk(root)) {
+ paths
+ .filter(Files::isRegularFile)
+ .filter(TestRunner::isClassFile)
+ .filter(TestRunner::isTopLevelClass)
+ .map(path -> className(root, path))
+ .sorted()
+ .forEach(name -> runClass(name, result));
+ }
+
+ printSummary(result);
+
+ if (result.failed != 0) {
+ System.exit(1);
+ }
+ }
+
+ private static boolean isClassFile(Path path) {
+ return path.toString().endsWith(".class");
+ }
+
+ private static boolean isTopLevelClass(Path path) {
+ return !path.getFileName()
+ .toString()
+ .contains("$");
+ }
+
+ private static String className(Path root, Path classFile) {
+ String relative = root
+ .relativize(classFile)
+ .toString();
+
+ String name = relative.substring(0, relative.length() - ".class".length()
+ );
+
+ return name.replace(File.separatorChar, '.');
+ }
+
+ private static void runClass(String className, Result result) {
+ try {
+ Class<?> testClass = Class.forName(className);
+
+ Runner runner =
+ testClass.getAnnotation(Runner.class);
+
+ if (runner == null) {
+ return;
+ }
+
+ runClass(testClass, runner, result);
+
+ } catch (ClassNotFoundException e) {
+ System.err.printf(
+ "[FAIL] unable to load %s: %s%n",
+ className,
+ e
+ );
+
+ result.failed++;
+ }
+ }
+
+ private static void runClass(
+ Class<?> testClass,
+ Runner runner,
+ Result result
+ ) {
+ String name = runner.name();
+
+ if (name.isEmpty()) {
+ name = testClass.getSimpleName();
+ }
+
+ System.out.println();
+ System.out.println(name);
+
+ for (Method method : testClass.getDeclaredMethods()) {
+ Case testCase = method.getAnnotation(Case.class);
+
+ if (testCase == null) {
+ continue;
+ }
+
+ runCase(testClass, method, testCase, result );
+ }
+ }
+
+ private static void runCase(Class<?> testClass, Method method, Case testCase, Result result) {
+ String name = testCase.name();
+
+ if (name.isEmpty()) {
+ name = method.getName();
+ }
+
+ if (!testCase.enabled()) {
+ System.out.printf(
+ " [SKIP] %s%n",
+ name
+ );
+
+ result.skipped++;
+ return;
+ }
+
+ if (!validCase(method)) {
+ System.err.printf(
+ " [FAIL] %s: invalid test case%n",
+ name
+ );
+
+ result.failed++;
+ return;
+ }
+
+ try {
+ Object instance = null;
+
+ if (!Modifier.isStatic(method.getModifiers())) {
+ instance = createInstance(testClass);
+ }
+
+ method.setAccessible(true);
+ method.invoke(instance);
+
+ System.out.printf(
+ " [PASS] %s%n",
+ name
+ );
+
+ result.passed++;
+
+ } catch (InvocationTargetException e) {
+ Throwable cause = e.getCause();
+
+ System.err.printf(
+ " [FAIL] %s: %s%n",
+ name,
+ cause
+ );
+
+ result.failed++;
+
+ } catch (ReflectiveOperationException e) {
+ System.err.printf(
+ " [FAIL] %s: %s%n",
+ name,
+ e
+ );
+
+ result.failed++;
+ }
+ }
+
+ private static boolean validCase(Method method) {
+ return method.getParameterCount() == 0
+ && method.getReturnType() == void.class;
+ }
+
+ private static Object createInstance(Class<?> testClass) throws ReflectiveOperationException {
+ var constructor =
+ testClass.getDeclaredConstructor();
+
+ constructor.setAccessible(true);
+
+ return constructor.newInstance();
+ }
+
+ private static void printSummary(Result result) {
+ System.out.println();
+
+ System.out.printf(
+ "Tests: %d passed, %d failed, %d skipped%n",
+ result.passed,
+ result.failed,
+ result.skipped
+ );
+ }
+
+ private static final class Result {
+ int passed;
+ int failed;
+ int skipped;
+ }
+
+ private TestRunner() {
+ }
+}