diff options
| -rw-r--r-- | build.xml | 145 | ||||
| -rw-r--r-- | ivy.xml | 28 | ||||
| -rw-r--r-- | src/moe/anita/mai/buffer/Buffer.java | 43 | ||||
| -rw-r--r-- | src/moe/anita/mai/buffer/MemBuf.java | 29 | ||||
| -rw-r--r-- | src/moe/anita/mai/buffer/SrcBuf.java | 44 | ||||
| -rw-r--r-- | src/moe/anita/mai/lexer/Lexer.java | 332 | ||||
| -rw-r--r-- | test/moe/anita/mai/buffer/MemBufTest.java | 51 | ||||
| -rw-r--r-- | test/moe/anita/mai/buffer/SrcBufTest.java | 72 | ||||
| -rw-r--r-- | test/moe/anita/mai/lexer/LexerNumberTest.java | 84 | ||||
| -rw-r--r-- | test/moe/anita/mai/lexer/LexerTest.java | 101 | ||||
| -rw-r--r-- | test/moe/anita/mai/test/Case.java | 26 | ||||
| -rw-r--r-- | test/moe/anita/mai/test/Runner.java | 21 | ||||
| -rw-r--r-- | test/moe/anita/mai/test/TestRunner.java | 209 |
13 files changed, 1050 insertions, 135 deletions
@@ -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=" "> - - <mapper> - <globmapper - from="${basedir}/*" - to=" <classpathentry kind="lib" path="*"/>"/> - </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() { + } +} |
