diff --git a/src/main/java/Sample.java b/src/main/java/Sample.java deleted file mode 100644 index 7cb5f74..0000000 --- a/src/main/java/Sample.java +++ /dev/null @@ -1,5 +0,0 @@ -public class Sample { - public String hello() { - return "Hello"; - } -} diff --git a/src/main/java/com/github/sttk/errs/Exc.java b/src/main/java/com/github/sttk/errs/Exc.java new file mode 100644 index 0000000..95f4e4b --- /dev/null +++ b/src/main/java/com/github/sttk/errs/Exc.java @@ -0,0 +1,220 @@ +/* + * Exc class. + * Copyright (C) 2025 Takayuki Sato. All Rights Reserved. + */ +package com.github.sttk.errs; + +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.io.Serializable; +import java.io.IOException; +import java.io.NotSerializableException; +import java.io.InvalidObjectException; + +/** + * Is the exception class with a reason. + *

+ * This class has a record field which indicates a reason for this exception. The class name of the reason record + * represents the type of reason, and the fields of the reason record hold the situation where the exception occurred. + *

+ * Optionally, this exception class can notify its instance creation to pre-registered exception handlers. + *

+ * The example code of creating and throwing an excepton is as follows: + * + *

{@code
+ * public record FailToDoSomething(String name, int value) {
+ * }
+ *
+ * try {
+ *     throw new Exc(new FailToDoSomething("abc", 123));
+ * } catch (Err e) {
+ *     System.out.println(e.getMessage()); // => "FailToDoSomething { name=abc, value=123 }"
+ * }
+ * }
+ */ +public final class Exc extends Exception { + + /** The serial version UID. */ + private static final long serialVersionUID = 260427082865587554L; + + /** The reason for this exception. */ + private transient Record reason; + + /** The stack trace for the location of occurrence. */ + private StackTraceElement trace; + + /** + * Is the constructor which takes a {@link Record} object indicating the reason for this exception. + * + * @param reason + * A reason for this exception. + */ + public Exc(final Record reason) { + if (reason == null) { + throw new IllegalArgumentException("reason is null"); + } + this.reason = reason; + + this.trace = getStackTrace()[0]; + } + + /** + * Is the constructor which takes a {@link Record} object indicating the reason and {@link Throwable} object + * indicating the cause for this exception. + * + * @param reason + * A reason for this exception. + * @param cause + * A cause for this exception. + */ + @SuppressWarnings("this-escape") + public Exc(final Record reason, final Throwable cause) { + super(cause); + + if (reason == null) { + throw new IllegalArgumentException("reason is null"); + } + this.reason = reason; + + this.trace = getStackTrace()[0]; + } + + /** + * Gets the reason for this exception. The type of the reason. + * + * @return The reason for this exception. + */ + public Record getReason() { + return this.reason; + } + + /** + * Returns the message of this exception, that is the reason. + * + * @return The message of this exception. + */ + @Override + public String getMessage() { + var rsn = this.reason.toString(); + var rname = this.reason.getClass().getSimpleName(); + rsn = rsn.substring(rname.length() + 1, rsn.length() - 1); + + var buf = new StringBuilder(this.reason.getClass().getName()); + buf.append(" { ").append(rsn).append(" }"); + return buf.toString(); + } + + /** + * Returns the detail message of this exception, that contains the reason, source file name, line number, and the + * cause if provided. + * + * @return The message of this exception. + */ + @Override + public String toString() { + var buf = new StringBuilder(getClass().getName()); + buf.append(" { reason = ").append(getMessage()); + buf.append(", file = ").append(this.trace.getFileName()); + buf.append(", line = ").append(this.trace.getLineNumber()); + if (getCause() != null) { + buf.append(", cause = ").append(getCause().toString()); + } + return buf.append(" }").toString(); + } + + /** + * Returns the name of the source file of this exception occurrance. + *

+ * This method can return null if this information is unavailable. + * + * @return The name of the source file of this error occurrence. + */ + public String getFile() { + return this.trace.getFileName(); + } + + /** + * Returns the line number of this exception occurrance in the source file. + *

+ * This method can return a negative number if this information is unavailable. + * + * @return The line number of this exception occurrance in the source file. + */ + public int getLine() { + return this.trace.getLineNumber(); + } + + /** + * Creates a {@link RuntimeException} object for methods that cannot throw a {@link Exc}. + * + * @return A {@link RuntimeException} object. + */ + public RuntimeException toRuntimeException() { + return new RuntimeExc(this); + } + + /** + * Writes a serial data of this exception to a stream. + *

+ * Since a {@link Record} object is not necessarily serializable, this method will throw a + * {@link NotSerializableException} if the {@code reason} field does not inherit {@link Serializable}. + * + * @param out + * An {@link ObjectOutputStream} to which data is written. + * + * @throws IOException + * if an I/O error occurs. + */ + private void writeObject(ObjectOutputStream out) throws IOException { + if (!(this.reason instanceof Serializable)) { + throw new NotSerializableException(this.reason.getClass().getName()); + } + out.defaultWriteObject(); + out.writeObject(this.reason); + } + + /** + * Reconstitutes the {@code Exc} instance from a stream and initialize the reason and cause properties when + * deserializing. If the reason by deserialization is null or invalid, this method throws + * {@link InvalidObjectException}. + * + * @param in + * An {@link ObjectInputStream} from which data is read. + * + * @throws IOException + * if an I/O error occurs. + * @throws ClassNotFoundException + * if a serialized class cannot be loaded. + */ + private void readObject(ObjectInputStream in) throws ClassNotFoundException, IOException { + in.defaultReadObject(); + this.reason = Record.class.cast(in.readObject()); + + if (this.reason == null) { + throw new InvalidObjectException("reason is null or invalid."); + } + } +} + +final class RuntimeExc extends RuntimeException { + private static final long serialVersionUID = 4664405757902479929L; + + RuntimeExc(Exc exc) { + super(exc); + } + + @Override + public String getMessage() { + return getCause().getMessage(); + } + + @Override + public String toString() { + return getClass().getName() + ": " + getCause().toString(); + } + + @Override + public Throwable fillInStackTrace() { + return null; + } +} diff --git a/src/main/java/com/github/sttk/errs/package-info.java b/src/main/java/com/github/sttk/errs/package-info.java new file mode 100644 index 0000000..cd6dfd3 --- /dev/null +++ b/src/main/java/com/github/sttk/errs/package-info.java @@ -0,0 +1,15 @@ +/* + * Copyright (C) 2024 Takayuki Sato. All Rights Reserved. + * + * This program is free software under MIT License. + * See the file LICENSE in this distribution for more details. + */ + +/** + * Provides classes for handling an exception with a reason. + *

+ * This package contains the {@code Exc} class which has a record field indicates the reason for the exception. + * + * @version 0.1 + */ +package com.github.sttk.errs; diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java new file mode 100644 index 0000000..d82c48d --- /dev/null +++ b/src/main/java/module-info.java @@ -0,0 +1,15 @@ +/* + * Copyright (C) 2025 Takayuki Sato. All Rights Reserved. + * + * This program is free software under MIT License. + * See the file LICENSE in this distribution for more details. + */ + +/** + * Contains a package which provides the APIs for handling an exception with a reason. + * + * @version 0.1 + */ +module com.github.sttk.errs { + exports com.github.sttk.errs; +} diff --git a/src/main/resources/META-INF/native-image/com.github.sttk/errs/serialization-config.json b/src/main/resources/META-INF/native-image/com.github.sttk/errs/serialization-config.json new file mode 100644 index 0000000..b94a756 --- /dev/null +++ b/src/main/resources/META-INF/native-image/com.github.sttk/errs/serialization-config.json @@ -0,0 +1,41 @@ +{ + "types": [ + { + "name": "com.github.sttk.errs.Exc" + }, + { + "name": "java.io.IOException" + }, + { + "name": "java.io.NotSerializableException" + }, + { + "name": "java.io.ObjectStreamException" + }, + { + "name": "java.lang.Exception" + }, + { + "name": "java.lang.RuntimeException" + }, + { + "name": "java.lang.StackTraceElement" + }, + { + "name": "java.lang.StackTraceElement[]" + }, + { + "name": "java.lang.String" + }, + { + "name": "java.lang.Throwable" + }, + { + "name": "java.util.Collections$EmptyList" + } + ], + "lambdaCapturingTypes":[ + ], + "proxies":[ + ] +} diff --git a/src/test/java/SampleTest.java b/src/test/java/SampleTest.java deleted file mode 100644 index 990d20a..0000000 --- a/src/test/java/SampleTest.java +++ /dev/null @@ -1,11 +0,0 @@ -import static org.assertj.core.api.Assertions.assertThat; -import org.junit.jupiter.api.Test; - -public class SampleTest { - - @Test - void hello() { - var sample = new Sample(); - assertThat(sample.hello()).isEqualTo("Hello"); - } -} diff --git a/src/test/java/com/github/sttk/errs/ExcTest.java b/src/test/java/com/github/sttk/errs/ExcTest.java new file mode 100644 index 0000000..350c7ce --- /dev/null +++ b/src/test/java/com/github/sttk/errs/ExcTest.java @@ -0,0 +1,317 @@ +package com.github.sttk.errs; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.fail; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledInNativeImage; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.io.Serializable; +import java.io.NotSerializableException; +import java.io.InvalidObjectException; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.io.IOException; + +public class ExcTest { + private ExcTest() { + } + + /// exception reasons /// + + record IndexOutOfRange(String name, int index, int min, int max) { + } + + record SerializableReason(String name, int index, int min, int max) implements Serializable { + } + + @Nested + class TestConstructor { + @Test + void with_reason() { + var exc = new Exc(new IndexOutOfRange("data", 4, 0, 3)); + var reason = IndexOutOfRange.class.cast(exc.getReason()); + assertThat(reason.name()).isEqualTo("data"); + assertThat(reason.index()).isEqualTo(4); + assertThat(reason.min()).isEqualTo(0); + assertThat(reason.max()).isEqualTo(3); + assertThat(exc.getCause()).isNull(); + + // exc.printStackTrace(); + } + + @Test + void with_reason_but_reason_is_null() { + try { + new Exc(null); + } catch (IllegalArgumentException e) { + assertThat(e.getMessage()).isEqualTo("reason is null"); + } + } + + @Test + void with_reason_and_cause() { + var cause = new IndexOutOfBoundsException(4); + var exc = new Exc(new IndexOutOfRange("data", 4, 0, 3), cause); + var reason = IndexOutOfRange.class.cast(exc.getReason()); + assertThat(reason.name()).isEqualTo("data"); + assertThat(reason.index()).isEqualTo(4); + assertThat(reason.min()).isEqualTo(0); + assertThat(reason.max()).isEqualTo(3); + assertThat(exc.getCause()).isEqualTo(cause); + + // exc.printStackTrace(); + } + + @Test + void with_reason_and_cause_but_reason_is_null() { + var cause = new IndexOutOfBoundsException(4); + try { + new Exc(null, cause); + } catch (IllegalArgumentException e) { + assertThat(e.getMessage()).isEqualTo("reason is null"); + } + } + + @Test + void with_reason_and_cause_but_cause_is_null() { + var exc = new Exc(new IndexOutOfRange("data", 4, 0, 3), null); + var reason = IndexOutOfRange.class.cast(exc.getReason()); + assertThat(reason.name()).isEqualTo("data"); + assertThat(reason.index()).isEqualTo(4); + assertThat(reason.min()).isEqualTo(0); + assertThat(reason.max()).isEqualTo(3); + assertThat(exc.getCause()).isNull(); + + // exc.printStackTrace(); + } + } + + @Nested + class TestThrow { + @Test + void identify_reason_with_instanceOf() { + var exc = new Exc(new IndexOutOfRange("data", 4, 0, 3)); + if (exc.getReason() instanceof IndexOutOfRange reason) { + assertThat(reason.name()).isEqualTo("data"); + assertThat(reason.index()).isEqualTo(4); + assertThat(reason.min()).isEqualTo(0); + assertThat(reason.max()).isEqualTo(3); + } + } + + @Test + void identify_reason_with_switch_expression() { + var exc = new Exc(new IndexOutOfRange("data", 4, 0, 3)); + switch (exc.getReason()) { + case IndexOutOfRange reason -> { + assertThat(reason.name()).isEqualTo("data"); + assertThat(reason.index()).isEqualTo(4); + assertThat(reason.min()).isEqualTo(0); + assertThat(reason.max()).isEqualTo(3); + } + default -> fail(); + } + } + } + + @Nested + class TestGetter { + @Test + void getReason() { + var exc = new Exc(new IndexOutOfRange("data", 4, 0, 3)); + assertThat(exc.getReason()).isInstanceOf(IndexOutOfRange.class); + + var reason = IndexOutOfRange.class.cast(exc.getReason()); + assertThat(reason.name()).isEqualTo("data"); + assertThat(reason.index()).isEqualTo(4); + assertThat(reason.min()).isEqualTo(0); + assertThat(reason.max()).isEqualTo(3); + } + + @Test + void getCause() { + var exc = new Exc(new IndexOutOfRange("data", 4, 0, 3)); + assertThat(exc.getCause()).isNull(); + + var cause = new IndexOutOfBoundsException(4); + exc = new Exc(new IndexOutOfRange("data", 4, 0, 3), cause); + assertThat(exc.getCause()).isEqualTo(cause); + } + + @Test + void getFile() { + var exc = new Exc(new IndexOutOfRange("data", 4, 0, 3)); + assertThat(exc.getFile()).isEqualTo("ExcTest.java"); + } + + @Test + void getLine() { + var exc = new Exc(new IndexOutOfRange("data", 4, 0, 3)); + assertThat(exc.getLine()).isEqualTo(155); + } + } + + @Nested + class TestGetMessage { + @Test + void with_cause() { + var exc = new Exc(new IndexOutOfRange("data", 4, 0, 3)); + assertThat(exc.getMessage()) + .isEqualTo("com.github.sttk.errs.ExcTest$IndexOutOfRange { name=data, index=4, min=0, max=3 }"); + } + + @Test + void with_no_cause() { + var cause = new IndexOutOfBoundsException(4); + var exc = new Exc(new IndexOutOfRange("data", 4, 0, 3), cause); + assertThat(exc.getMessage()) + .isEqualTo("com.github.sttk.errs.ExcTest$IndexOutOfRange { name=data, index=4, min=0, max=3 }"); + } + } + + @Nested + class TestToString { + @Test + void with_reason() { + var exc = new Exc(new IndexOutOfRange("data", 4, 0, 3)); + assertThat(exc.toString()).isEqualTo( + "com.github.sttk.errs.Exc { reason = com.github.sttk.errs.ExcTest$IndexOutOfRange { name=data, index=4, min=0, max=3 }, file = ExcTest.java, line = 182 }"); + } + + @Test + void with_reason_and_cause() { + var cause = new IndexOutOfBoundsException(4); + var exc = new Exc(new IndexOutOfRange("data", 4, 0, 3), cause); + assertThat(exc.toString()).isEqualTo( + "com.github.sttk.errs.Exc { reason = com.github.sttk.errs.ExcTest$IndexOutOfRange { name=data, index=4, min=0, max=3 }, file = ExcTest.java, line = 190, cause = java.lang.IndexOutOfBoundsException: Index out of range: 4 }"); + } + } + + @Nested + class TestToRuntimeException { + @Test + void getMessage() { + var exc = new Exc(new IndexOutOfRange("data", 4, 0, 3)); + var rtExc = exc.toRuntimeException(); + assertThat(rtExc.getMessage()) + .isEqualTo("com.github.sttk.errs.ExcTest$IndexOutOfRange { name=data, index=4, min=0, max=3 }"); + } + + @Test + void getCause() { + var exc = new Exc(new IndexOutOfRange("data", 4, 0, 3)); + var rtExc = exc.toRuntimeException(); + assertThat(rtExc.getCause()).isEqualTo(exc); + } + + @Test + void printStackTrace() { + var exc = new Exc(new IndexOutOfRange("data", 4, 0, 3)); + var rtExc = exc.toRuntimeException(); + + var swOfExc = new StringWriter(); + try (var pwOfExc = new PrintWriter(swOfExc)) { + exc.printStackTrace(pwOfExc); + } + var swOfRtExc = new StringWriter(); + try (var pwOfRtExc = new PrintWriter(swOfRtExc)) { + rtExc.printStackTrace(pwOfRtExc); + } + + var isWindows = System.getProperty("os.name").toLowerCase().startsWith("windows"); + var prefix = "com.github.sttk.errs.RuntimeExc: " + exc.toString(); + if (isWindows) { + prefix += System.lineSeparator(); + } else { + prefix += System.lineSeparator(); + } + prefix += "Caused by: "; + + assertThat(swOfRtExc.toString()).isEqualTo(prefix + swOfExc.toString()); + + // rtExc.printStackTrace(); + } + } + + @Nested + class TestSerialize { + @Test + void reason_is_serializable_and_has_no_cause() throws Exception { + var bos = new ByteArrayOutputStream(); + var oos = new ObjectOutputStream(bos); + try (oos) { + var exc = new Exc(new SerializableReason("data", 4, 0, 3)); + oos.writeObject(exc); + } + + var bytes = bos.toByteArray(); + var ois = new ObjectInputStream(new ByteArrayInputStream(bytes)); + try (ois) { + var obj = ois.readObject(); + assertThat(obj).isInstanceOf(Exc.class); + + var exc = Exc.class.cast(obj); + var cause = exc.getCause(); + assertThat(cause).isNull(); + + var robj = exc.getReason(); + assertThat(robj).isInstanceOf(SerializableReason.class); + var reason = SerializableReason.class.cast(robj); + assertThat(reason.name()).isEqualTo("data"); + assertThat(reason.index()).isEqualTo(4); + assertThat(reason.min()).isEqualTo(0); + assertThat(reason.max()).isEqualTo(3); + } + } + + @Test + void reason_is_serializable_and_has_cause() throws Exception { + var bos = new ByteArrayOutputStream(); + var oos = new ObjectOutputStream(bos); + try (oos) { + var cause = new IndexOutOfBoundsException(4); + var exc = new Exc(new SerializableReason("data", 4, 0, 3), cause); + oos.writeObject(exc); + } + + var bytes = bos.toByteArray(); + var ois = new ObjectInputStream(new ByteArrayInputStream(bytes)); + try (ois) { + var obj = ois.readObject(); + assertThat(obj).isInstanceOf(Exc.class); + + var exc = Exc.class.cast(obj); + var cause = exc.getCause(); + assertThat(cause).isInstanceOf(IndexOutOfBoundsException.class); + assertThat(cause.getMessage()).isEqualTo("Index out of range: 4"); + + var robj = exc.getReason(); + assertThat(robj).isInstanceOf(SerializableReason.class); + var reason = SerializableReason.class.cast(robj); + assertThat(reason.name()).isEqualTo("data"); + assertThat(reason.index()).isEqualTo(4); + assertThat(reason.min()).isEqualTo(0); + assertThat(reason.max()).isEqualTo(3); + } + } + + @Test + void reason_is_not_serializable() throws Exception { + var bos = new ByteArrayOutputStream(); + var oos = new ObjectOutputStream(bos); + try (oos) { + var exc = new Exc(new IndexOutOfRange("data", 4, 0, 3)); + oos.writeObject(exc); + fail(); + } catch (NotSerializableException e) { + assertThat(e.getMessage()).isEqualTo(IndexOutOfRange.class.getName()); + } + } + } +} diff --git a/src/test/resources/META-INF/native-image/serialization-config.json b/src/test/resources/META-INF/native-image/serialization-config.json new file mode 100644 index 0000000..c64134f --- /dev/null +++ b/src/test/resources/META-INF/native-image/serialization-config.json @@ -0,0 +1,14 @@ +{ + "types":[ + { + "name": "com.github.sttk.errs.ExcTest$SerializableReason" + }, + { + "name": "java.lang.IndexOutOfBoundsException" + } + ], + "lambdaCapturingTypes":[ + ], + "proxies":[ + ] +}