Onj is a simple language with a json-like syntax used mainly for
writing config files. It is mainly intended for files written by a
human and read by a computer. It's file extension is onj
.
The top level of an .onj file is always an object consisting of key-value pairs.
key: "value",
key2: 'value',
boolean: true,
int: 34,
float: 1.23,
nullValue: null,
Key-value pairs are separated by commas, trailing commas are allowed.
Onj supports the following datatypes:
- boolean
- int (64-bit signed)
- float (64-bit)
- string
- objects
- arrays
- null-type
Objects are declared using curly braces containing more key-value pairs.
object: {
key: "value",
otherKey: "value",
nestedObject: {
key: "value"
}
}
Arrays are declared using square brackets containing values separated by commas.
arr: [
"value", 1, 2, true
]
Keys containing special characters have to be wrapped in quotes.
"I contain Spaces!": true,
'123 I start with a number!': true
Onj supports line or block comments. Line comments are declared
using //
and block comments are started with /*
and closed
with */
. If a block comment is not closed, it will go on to the
end of the file.
/*
a block comment
*/
// a line comment
/*
unterminated block comment
Variables can be used to extract common values to a single place and
reuse them. They are declared at the top-level using
the var
keyword.
var obj = {
key: "value"
};
var number = 5;
first: obj,
second: obj,
favoriteNumber: number,
arr: [
true,
number,
obj
]
The following global variables are provided by default: NaN
,
infinity
.
When objects or arrays only share some values and not others, they can still be extracted to a variable and then included using the triple-dot syntax.
var catKeys = {
type: "cat",
amountLegs: 4
};
pets: [
{
name: "Lilly",
...catKeys
},
{
name: "Bello",
type: "dog",
amountLegs: 4,
},
{
...catKeys,
name: "Snowflake"
}
],
var commonFruits = [ "apple", "orange" ];
fruitSaladOne: [
"pear",
...commonFruits,
"blueberry"
],
fruitSaladTwo: [
"pineapple",
"strawberry",
...commonFruits
]
Onj supports simple mathematical expressions.
var two = 1 + 1;
five: 1 + two * (2 / 1),
negative: -two
Onj does integer division when both operands are integers. For all operations the following rule applies: If one operand is a float, the result is a float.
By putting a hash and an identifier behind a value it can be converted to different datatype.
var anInt = 2;
aFloat: anInt#float,
aString: anInt#string,
toFloatToString: anInt#float#string
If a variable is of type object or array, the variable access syntax can be used to access values from it.
var colors = {
red: "#ff0000",
green: "#00ff00",
blue: "#0000ff"
};
// properties of objects can be accessed by using a dot
// followed by an identifier
favoriteColor: color.blue,
// identifiers can be wrapped in quotes
anotherColor: color."green",
var animals = [ "cat", "dog", "bird", "fish" ];
// values of arrays can be accessed by using an integer
// after the dot
favoriteAnimal: animals.0, // arrays are zero-indexed
var outer = {
nested: {
arr: [ "a value" ]
}
};
accessingNested: outer.nested.arr.0
By putting parenthesis after the dot, the access can be made dynamic. The value inside the parenthesis must resolve to a string when accessing an object, or to an int when accessing an array.
var indexOffset = 2;
var arr = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15];
dynamic: arr.(indexOffset + 4),
var strings = [ "a", "b", "c", "d" ];
var object = {
a: 0,
b: 1,
c: 2,
d: 3
};
dynamic2: object.(strings.(object.a))
Imports can be used to split up large files or to extract structures used across multiple files.
import "path/from/working/dir/file.onj" as imported;
someValue: imported
The import statement will create a variable with the name
specified after the as
. This variable contains the structure
of the imported file.
The path to the file can be dynamic.
var paths = [
"fist/path/file.onj",
"second/path/file.onj"
];
import (paths.0) as imported;
Onj has the sqrt, pow and in functions built-in.
four: sqrt(16.0),
// pow and in are infix functions, meaning you can call them
// using the following syntax: param1 function param2
nine: 3 pow 2,
// But they can be called using the conventional syntax too
alsoNine: pow(3, 2),
// the in-function checks if a value is present in an array
isTrue: 1 in [ 1, 2, 3, 4 ]
Schemas can be used to validate the structure of a .onj file.
The file extension for schema files is typically .onjschema
.
The syntax of onjschema files is very similar to onj files, but instead of values, they specify data types. Some syntactical structures (like variable accesses) are not supported by onjschemas.
anInt: int,
aString: boolean,
anArrayLiteral: [ boolean, string, int ],
anObject: {
aFloat: float,
aString: string
}
Objects must contain exactly the same keys with matching datatypes and cannot contain keys not specified in the schema. The same applies for array literals.
// To indicate that a value is nullable, use a question mark
// behind the type
nullableBool: boolean?,
// To indicate that a key is optional, use a question mark behind
// the key
optionalKey?: int,
// To make a type into an array, use square brackets
arrOfArbitraryLength: int[],
// To specify the length of an array, write a number in the
// square brackets
arrOfLengthFive: int[5],
// To indicate that the value can be any type, use a star
any: *,
// To indicate that an object can contain keys that where not
// specified use the triple-dot syntax followed by a star
...*,
// Keys that where not specified can have any type
// types are always read from left to right
key: int?[]?, // a nullable array of nullable ints
// the question mark and the square brackets can be used on
// objects or arrays as well
nullableArr: [ string, int ]?,
objectArr: { x: float, y: float }[]
To better explain why named objects are needed, I'll start with the problem they are trying to solve. Imagine you want to represent an ui in an onj file. You need different widgets (labels, images) and groups (HBoxes, VBoxes), however, depending on the widget they will need different keys. While a label will need a 'text' key, an image will need a 'image-path' key. One way you could implement this is the following:
screen.onj
root: {
type: "VBox",
children: [
{
type: "label",
text: "I'm a label!",
font: "Comic Sans"
},
{
type: "image",
imagePath: "path/to/image.png"
}
]
}
screen.onjschema
root: {
type: string,
// Because each widget requires different keys, we need to
// allow all keys
...*
}
However, the above approach is not very good, because the schema file is essentially worthless and the validation would have to be almost completely done by the programmer.
Named objects try to solve this problem by giving names to objects and requiring different keys depending on the name. Additionally, multiple named objects can be grouped together in a named object group, that can than be used as a type in your schema file.
Here is the better solution using named objects:
screen.onj
root: $VBox { // declares an object with the name VBox
children: [
$Label {
type: "label",
text: "I'm a label!",
font: "Comic Sans"
},
$Image {
imagePath: "path/to/image.png"
}
]
}
screen.onjschema
$Widget { // declares a named object group named Widget
// declares a object with name HBox in the Widget group
$HBox {
// the name of a object group can be used like a datatype
// to allow any of the objects in it
children: $Widget[]
}
$VBox {
children: $Widget[]
}
$Label {
type: string
text: string
font: string
}
$Image {
imagePath: string
}
}
root: $Widget
Note: when declaring multiple named object groups the object names need to be unique even across different groups.
.onj files can be parsed using the companion object of OnjParser.
// using a string
val structure = OnjParser.parseFile("path/file.onj")
// using a file
OnjParser.parseFile(Paths.get(path).toFile())
// parsing a string directly
OnjParser.parse("key: 'value'")
If an error occurs during parsing, the parser will throw an OnjParserException. (This also means only the first syntax error will be reported, I'm aware that this is not great)
val structure = try {
OnjParser.parseFile("path/file.onj")
} catch (e: OnjParserException) {
null
}
val schema = OnjSchemaParser.parseFile("file.onjschema")
val onj = OnjParser.parseFile("file.onj")
// throws a OnjSchemaException when the schema doesn't
// match the file
schema.assertMatches(onj)
// returns null when the schema matches, or a string containing
// an error message otherwise
val result = schema.check(onj)
// casting is now safe
onj as OnjObject
val schema = OnjSchemaParser.parseFile("file.onjschema")
val onj = OnjParser.parseFile("file.onj")
schema.assertMatches(onj)
onj as OnjObject
// a map containing the keys can be accessed using .value
val keys: Map<String, OnjValue> = onj.value
// .value is of type String here
val string = (keys["myString"] as OnjString).value
// Instead the generic .get function can be used instead.
// An Exception will be thrown if the key doesn't exist
// or the key has a wrong type, but in this case the schema
// check would have crashed first
val string = onj.get<OnjString>("myString").value
// To save the .value access, the type of .value can be used in
// the .get function instead, which will then access .value itself
val string = onj.get<String>("myString")
// Be careful! Because onj uses 64-bit datatypes, an OnjInt will be
// a Long, and an OnjFloat will be a Double
val onjInt = onj.get<Long>("myInt")
val schema = OnjSchemaParser.parseFile("file.onjschema")
val onj = OnjParser.parseFile("file.onj")
schema.assertMatches(onj)
onj as OnjObject
val arr = onj.get<OnjArray>("arr")
// loop over all values
arr
.value
.forEach { onjValue ->
println(onjValue.value)
}
// access an index
// throws an IndexOutOfBoundsException if the index doesn't exist
val first = arr[0] as OnjString
// Objects can be created using the buildOnjObject function.
val obj = buildOnjObject {
// here, the with function can be used to declare a key
"myKey" with OnjString("someValue")
// simple values can be converted to OnjValues automatically
"myString" with "aString"
"myBool" with true
// includes all keys of another object or of a Map<String, OnjValue>
includeAll(anotherObject)
"nestedObject" with buildOnjObject {
"key" with 34
}
// Arrays or Collections will be automatically converted
// to OnjArrays, including the contained values
"arr" with arrayOf(
true, "string", null
)
}
// Arrays can be created using the .toOnjArray extension function
// on Collection<*> and Array<*>. Like buildOnjObject this function
// will attempt to convert values to OnjValues
val arr = arrayOf(true, false, "string").toOnjArray()
// a similar functions for objects exists on Map<String, *>
val obj = mapOf(
"key" to true,
"key2" to "string"
).toOnjObject()
The OnjValue class also provides functions to convert the structure back to a string or even a json string. However, when reading a file and writing it again, things like variables, imports and calculations are lost.
val onj = OnjParser.parseFile("file.onj")
val asString = onj.toString()
val asJson = onj.toJsonString()
Namespaces allow adding custom functions, operator overloads, conversions, global variables and datatypes.
Namespaces are typically objects and annotated with the
@OnjNameSpace
annotation.
Functions are registered using the @RegisterOnjFunction
annotation. This annotation takes a parameter containing a
string containing an onjschema with a key named 'params'
and a value of type array. This array tells the OnjParser
which types this functions takes and should match the actual
signature of the function. The function can only take types
that extend OnjValue and must return a type that extends
OnjValue as well.
@OnjNameSpace
object MyNamespace {
@RegisterOnjFunction(schema = "params: [string]")
fun greeting(name: OnjString): OnjString {
return OnjString("hello, ${name.value}!")
}
}
The @RegisterOnjFunction
annotation also takes a second,
optional parameter that indicates the type of function. The default
type is normal
, but it can also be set to infix
,
operator
or conversion
.
infix
signals that a function can be used as an infix-function
(callable using the param1 function param2
syntax). The function
must take exactly two parameters.
operator
indicates that the function overloads an operator.
It's name must be one of: plus, minus, star, div. It must take
exactly two parameters.
conversion
indicates that the function can be called using the
conversion syntax (value#function
). It must take exactly one
parameter.
To add custom global variables to the namespace create a field of
type Map<String, OnjValue> and annotate it with the
@OnjNamespaceVariables
annotation. This map contains the
variable names as keys and the value of the variable as value.
@OnjNameSpace
object MyNamespace {
@OnjNamespaceVariables
val variables: Map<String, OnjValue> = mapOf(
"variable" to OnjString("value")
)
}
To add a custom datatype, first the class representing it must be created. It has to extend OnjValue.
class OnjColor(
// the abstract field 'value' must be overridden
override val value: Color // Color is an imaginary class
) : OnjValue() {
// OnjValue requires you to override the stringify functions, which is used for converting structures to onj or
// json strings. The info parameter provides a StringBuilder that the function should append to and provides
// information on whether the resulting string is onj or json, should be minified, and what indentation should be
// used.
override fun stringify(info: ToStringInformation) {
info.builder.append("color('${value.toHexString()}')")
}
}
To make the type available in onjschemas you need to go into your
namespace and create a field of type Map<String, KClass<*>> and
annotate it with the @OnjNamespaceDatatypes
. The Map contains the
name of the datatype as the key and the KClass as the value.
@OnjNameSpace
object MyNamespace {
@OnjNamespaceDatatypes
val datatypes: Map<String, KClass<*>> = mapOf(
"Color" to OnjColor::class
)
}
Lastly, you need a way to actually create a value of the added type. This is commonly done using a function.
@OnjNameSpace
object MyNamespace {
@RegisterOnjFunction(schema = "params: [string]")
fun color(hex: OnjString): OnjColor {
return OnjColor(Color.fromHex(hex.value))
}
}
fun init() {
OnjConfig.registerNameSpace(MyNamespace, "MyNamespace")
}
To include the namespace in an onj or onjschema file, the use
keyword is used.
file.onj
use MyNamespace;
myColor: color("00ff00"),
myGlobal: variable,
myFunction: greeting("reader")
file.onjschema
use MyNamespace;
myColor: Color,
myGlobal: string,
myFunction: string