Julienne
A Fortran 2023 correctness-checking framework supporting expressive idioms for writing assertions and tests
Install / Use
/learn @BerkeleyLab/JulienneREADME
Julienne: Idiomatic Correctness Checking for Fortran 2023
The Julienne framework offers a unified approach to writing unit tests and assertions. Julienne defines idioms for specifying correctness conditions in a common in tests that wrap the tested procedures or assertions that conditionally execute inside procedures. Julienne idioms center around expressions built from defined operations: a uniquely flexible Fortran capability allowing developers to define new operators or to overloading Fortran's intrinsic operators.
Example expressions | Supported operand types
-----------------------------------------------------|--------------------------------------
x .approximates. y .within. tolerance | real, double precision for x, y, tolerance
x .approximates. y .withinFraction. tolerance | real, double precision for x, y, tolerance
x .approximates. y .withinPercentage. tolerance | real, double precision for x, y, tolerance
.all. ([i,j] .lessThan. k) | test_diagnosis_t for .all. operator's operand
.all. ([i,j] .lessThan. [k,m]) | test_diagnosis_t for .all. operator's operand
.all. (i .lessThan. [k,m]) | test_diagnosis_t for .all. operator's operand
(i .lessThan. j) .also. (k .equalsExpected. m)) | test_diagnosis_t for .also. operator's operands
x .lessThan. y | integer, real, double precision for x, y
x .greaterThan. y | integer, real, double precision for x, y
i .equalsExpected. j | integer, character, type(c_ptr) for i, j
i .isAtLeast. j | integer, real, double precision for i, j
i .isAtMost. j | integer, real, double precision for i, j
s .isBefore. t | character for s, t
s .isAfter. t | character for s, t
.expect. allocated(A) // " (expected allocated A)" | logical for .expect. operator's operand
where
.isAtLeast.and.isAtMost.can alternatively be spelled.greaterThanOrEqualTo.and.lessThanOrEqualTo., respectively;.equalsExpected.uses==, which implies that trailing blank spaces are ignored in character operands;.equalsExpected.with integer operands supports default integers andinteger(c_size_t);.isBefore.and.isAfter.verify alphabetical and reverse-alphabetical order, respectively;.all.aggregates arrays of expression results, reports a consensus result, and shows diagnostics only for failing tests, if any;.equalsExpected.generates asymmetric diagnostic output for failures, denoting the left- and right-hand sides as the actual value and expected values, respectively; and//appends the subsequent string to diagnostics strings, if any.
Expressive idioms
Assertions
Any of the above expressions can be the actual argument in an invocation of
Julienne's call_julienne_assert function-line preprocessor macro:
call_julienne_assert(x .lessThan. y)
which a preprocessor will replace with a call to Julienne's assertion subroutine
when compiling with -DASSERTIONS. Otherwise, the preprocessor will remove the
above line entirely.
Unit tests
The above tabulated expressions can also serve as results in unit-test functions.
Constraints
All operands in an expression must be compatible in type and kind as well as
conformable in rank. Conformability implies that the operands must be all
scalars or all arrays with the same shape or a combination of scalars and arrays
with the same shape. This constraint follows from each of the binary operators
being elemental. The unary .all. operator applies to operands of any rank.
Each expression tabulated above produces a test_diagnosis_t object with two
components:
- a
logicalindicator of test success if.true. or failure if.false.and - an automated diagnostic messages generated only if the test or assertion fails.
Custom Test Diagnostics
For cases in which the defined operations do not support a desired correctness
condition, Julienne provides string-handling utilities for use in crafting
custom diagnostic messages. The string utilities center around a string_t
derived type, which offers elemental constructor functions, i.e., functions
that one invokes via the same name as the derived type: string_t(). The
string_t() constructor functions convert data of numeric type to character
type, storing the resulting character representation in a private component
of the constructor function result. The actual argument provided to the
constructor function can be of any one of several types, kinds, and ranks.
Julienne provides defined operations for concatenating string_t objects
(//), forming a concatenated string_t object from an array of string_t
objects (.cat.), forming a separated-value list (.separatedBy. or
equivalently .sv.), including a comma-separated value list (.csv.).
Example expression | Result
-------------------------------------------------|------------------------------------------------
s%bracket(), where s=string_t("abc"), | string_t("[abc]")
s%bracket("_"), where s=string_t("abc") | string_t("_abc_")
s%bracket("{","}"), where s=string_t("abc") | string_t("{abc}")
string_t(["a", "b", "c"]) | [string_t("a"), string_t("b"), string_t("c")]
.cat. string_t([9,8,7]) | string_t("987")
.csv. string_t([1.5,2.0,3.25]) | string_t("1.50000000,2.00000000,3.25000000")
string_t([1,2,4]) .separatedBy. "-" | string_t("1-2-4")
string_t("ab") // string_t("cd") | string_t("abcd")
"ab" // string_t("cd") | string_t("abcd")
string_t("ab") // "cd" | string_t("abcd")
One can use such expressions to craft a diagnostic message:
type(test_diagnosis_t) test_diagnosis
test_diagnosis = test_diagnosis_t( &
test_passed = i==j, &
diagnostics_string = "expected " // string_t(i) // "; actual " //string_t(j) &
)
A file abstraction
Arrays of string_t objects provide a convenient way to store a ragged-length
array of character data. Julienne's file_t derived type has a private
component that is a string_t array, wherein each element is one line of a text
file. By storing a file in a file_t object using the file_t derived type's
constructor function one can confine a program's file input/output (I/O) to one
or two procedures. The resulting file_t object can be manipulated elsewhere
without incurring the costs associated with file I/O. For example, the following
lines read a file named data.txt into a file_t object and associates the name
file with the resulting object.
associate(file => file_t("data.txt"))
end associate
This style supports functional programming patterns in two ways. First, the rest
of the program can be comprised of pure procedures, which are precluded from
performing I/O. Second, an associate name is immutable when associated with an
expression, including an expression that is simply a function reference.
Functional programming revolves around creating and using immutable state.
(By contrast, when associating a name with a variable or array instead of with
an expression, only certain attributes, such as the entity's allocation status,
are immutable. The value of such a variable or array can be redefined.)
Functional Programming
Functional programming patterns centered around pure procedures enhance
code clarity, ease refactoring, and encourage optimization. For example,
the constraints on pure procedures make it easier for a developer or a
compiler to safely reorder program statements. Moreover, Fortran allows
invoking only pure procedures inside do concurrent, a construct that
compilers can automatically offload to a graphics processing unit (GPU).
Julienne lowers a widely stated barrier to writing pure procedures (including
simple procedures): the difficulty in printing values while debugging code.
The Julienne philosophy is that printing a value for debugging purposes implies
an expectation about the value. Assert such expectations by writing Julienne
expressions inspired by natural language. A program will proceed quietly past
a correct assertion. An incorrect assertion produces either automated or custom
diagnostic messages during error termination.
Getting Started
Writing Unit Tests
Please see demo/README.md for a detailed demonstration of test setup.
Writing Assertions
To write a Julienne assertion, insert a function-like preprocessor macro
call_julienne_assert on a single line as in each of the two macro
invocations below:
#include "julienne-assertion-macros.h"
program main
use, julienne_m, only : call_julienne_assert_
implicit none
real, parameter :: x=1., y=2., tolerance=3.
call_julienne_assert(x .approximates. y .within. tolerance)
call_julienne_assert(abs(x-y) < tolerance)
end program
where inserting -DASSERTIONS in a compile command will expand the macros to
call call_julienne_assert_(x .approximates. y .within. tolerance, __FILE__, __LINE__)
call call_julienne_assert_(allocated(a), __FILE__, __LINE__)
and where dots (.) delimit Julienne operators. The above expression conta
