Read and write SOML, a config format for humans, in AppleScript
Note
The format is a draft.
- Strict: enforces every rule of the specification, with the same error positions and messages as the JavaScript reference implementation
- Exact: every int64, date, instant, and duration survives,
3and3.0stay different, and keys keep their exact spelling - Native: an object is a record, so a value is
|port| of |server| of config - Canonical:
encodeCanonical()gives equal text for equal values, andencode()keeps the order of a record's labels - Safe: invalid UTF-8 is an error, deep input cannot overflow the stack, and the caller's text item delimiters are kept
- A script library with terminology, for AppleScript 2.8 on macOS 12 or later
Download SOML.scptd.zip, unzip it, and move SOML.scptd to ~/Library/Script Libraries.
Or build it from source:
git clone https://github.com/soml-lang/soml-applescript
cd soml-applescript
make installThis compiles the library to ~/Library/Script Libraries/SOML.scptd. A failed install keeps the previous version, and a replaced version goes to the Trash. The make commands require the Xcode Command Line Tools and macOS 15 or later, for the trash command.
# config.soml
name: 'api-gateway'
replicas: 3
timeout: 1m30s
launch-date: 2027-09-19
server: {
host: 'localhost'
port: 8080
}
servers: [
{host: 'a.internal'}
{host: 'b.internal'}
]
deps: {'socket.io': '^4'}
use AppleScript version "2.8"
use scripting additions
use soml : script "SOML"
set config to soml's decodeFile("/path/to/config.soml")
|name| of config
--=> "api-gateway"
|port| of |server| of config
--=> 8080
|host| of item 2 of |servers| of config
--=> "b.internal"
|socket.io| of |deps| of config
--=> "^4"
soml's toSeconds(|timeout| of config)
--=> 90.0Tip
Always write keys in bars, as in |port| of |server| of config. Without bars, a key that is also an AppleScript or scripting addition term, such as name, id, host, port, or timeout, means something else.
To write:
soml's encode({|name|:"api-gateway", replicas:3, ratio:0.5, tags:{"edge", "public"}})name: 'api-gateway'
replicas: 3
ratio: 0.5
tags: [
'edge'
'public'
]
| SOML | AppleScript |
|---|---|
| object | record, with the keys as labels, in document order |
| array | list |
| string | text |
| int | integer, from -536870912 to 536870911, and otherwise {class:SOML int, value:"9007199254740993"} |
| float | real, including infinity and -infinity |
| bool | boolean |
| null | missing value |
| date | {class:SOML date, value:"2027-09-19"}, without time or timezone |
| instant | {class:SOML instant, value:"2026-09-19T12:00:00Z"}, in UTC |
| duration | {class:SOML duration, value:"1h30m"} |
SOML int, SOML date, SOML instant, SOML duration, and SOML object are classes in the library's terminology, so class of value is SOML instant works in a script with use soml : script "SOML". The value of each is its canonical SOML spelling. A SOML date is a calendar day in years 0001–9999, not an AppleScript date, which is an instant. Quoted dates stay strings.
An object that a record cannot hold is {class:SOML object, entries:{{key, value}, …}}, which entries() reads like a record. That is:
- an empty object, because AppleScript has no empty record (
{}is a list), - an object with two keys that differ only in ASCII letter case, such as
aandA, because AppleScript ignores case in labels, - an object with a key that AppleScript would spell differently. AppleScript keeps the first spelling of a label it sees for the life of the process, so after a record with the label
URL, a new labelurlbecomesURL. So whether an object is a record can depend on what the process read before, - every object of a document with a label that AppleScript cannot expose, such as some long labels,
- every object of a document with more than 4,000 distinct keys, or read when AppleScript has no room for new labels (see Limitations),
- every object of a document with a string or key that starts with U+FEFF or U+FFFE, because the Apple event that gives a record its labels changes these characters.
An integer that AppleScript arithmetic makes above 1073741823 or below -1073741824 is a real, as in 1000 * 1000 * 1000, so it is written as a float, 1000000000.0. Use {class:SOML int, value:"1000000000"} for an int of any size.
encode() and encodeCanonical() also take:
- an AppleScript
date, as an instant (in local time, with whole seconds), null, as null,- a record label that is a keyword, such as
nameorbutton returned, as the key it spells, - the tagged values in any spelling that SOML accepts, as in
{class:SOML int, value:"0xFF"}and{class:SOML instant, value:"2026-09-19T14:00:00+02:00"}.
Handlers resolve loop and item references passed as their argument. To store a loop variable in a list or record, use contents of loopVariable.
Read a document from text. Returns a record, a list, or a SOML object.
Read a document from a file, given as a POSIX path, a file, or an alias, as UTF-8. Invalid UTF-8 is an error at its position, where read … as «class utf8» accepts it.
Write a record, a list, or a SOML object as a document, keeping the order of record labels. The text ends with a line feed.
Write a value in canonical form: one exact text for each value, with object keys sorted, for hashing and comparing.
The keys and values of a record or a SOML object, in order: {{key, value}, …}. Use it to read an object whose keys are not known in advance, as AppleScript cannot list the labels of a record.
repeat with theEntry in soml's entries(|deps| of config)
set {theName, theVersion} to theEntry
end repeatA record with the given keys and values: {{key, value}, …}. Gives a SOML object where a record cannot hold or expose the keys exactly, as described under Types.
Opaque values may use the SOML object form so their original values are preserved.
soml's fromEntries({{"socket.io", "^4"}, {"react", "^19"}})
--=> {|socket.io|:"^4", react:"^19"}An int as an AppleScript number: an integer as it is, and a SOML int as a real up to 2^53. A larger SOML int is an error.
A SOML instant as an AppleScript date, in local time. A fraction of a second is dropped.
A SOML duration as a number of seconds, a real. It is exact up to about 104 days, and rounded beyond that.
| Number | Error |
|---|---|
| 1 | The document is not valid. The message ends with the position, as in … at line 2, column 11, and from is {reason:…, |line|:2, |column|:11}. Lines and columns start at 1, and columns count Unicode scalar values. |
| 2 | The value cannot be written or converted, such as a script object, a text with a carriage return or a lone surrogate, NaN, a duplicate key, a SOML int too large for an exact number, or a malformed tagged value. The message names the value or its path, as in servers[0].ratio. |
| -1703 | A handler got an argument of the wrong type, or a tagged value whose text is not valid. |
| -43 | decodeFile() could not read the file. |
| -2707 | AppleScript has no room for new record labels (see Limitations). |
try
soml's decode("name: api-gateway")
on error message number errorNumber from details
message
--=> "Unexpected “api-gateway”. A string value must be quoted, as in 'api-gateway' at line 1, column 7"
|line| of details
--=> 1
end tryset config to soml's decodeFile("/path/to/config.soml")
set |port| of |server| of config to 9090
set encodedConfig to soml's encode(config)
set theFile to open for access (POSIX file "/path/to/config.soml") with write permission
try
set eof theFile to 0
write encodedConfig to theFile as «class utf8»
close access theFile
on error message number errorNumber
close access theFile
error message number errorNumber
end tryThis keeps the order of the keys, but not comments or layout.
Comments, key order, and spelling do not change the canonical form:
do shell script "printf %s " & quoted form of soml's encodeCanonical(config) & " | shasum -a 256"- No formatter or editor. Comments and layout are not kept.
- Speed. AppleScript is slow, so a few-kilobyte config takes about 16 ms to read, and 733 KB takes about 3 seconds. See the benchmark.
- Floats. JavaScriptCore converts floats, because AppleScript's own conversions round and use the locale (
0,5in some regions).-0.0reads as0.0, as the specification says. - AppleScript dates. An AppleScript date is in local time with whole seconds, so
toDate()drops a fraction of a second. In the hour that repeats when daylight saving time ends, the system picks one of the two instants. - Record labels. A process holds about 8,000 distinct record labels, counting those of every loaded script. When the table is full, AppleScript fails with error -2707, “Internal table overflow”, also in AppleScriptObjC calls. The library does not add labels for documents and
fromEntries()lists with more than 4,000 keys, but many smaller documents with different keys, or a script that makes many records, can still fill the table. It then stays full for the life of the process, so later documents giveSOML objects. - Surrogates.
encode()rejects a lone surrogate in a string, but can miss one at the end of a text, because AppleScript reports the codes of such a text inconsistently.
make bench
make bench FIXTURE=service SAMPLES=5Median time per document for the shared benchmark fixtures and the same data as JSON, on an Apple M4 Pro with macOS 27.0. service is a 3 KB config, platform a 35 KB config with many comments, and earthquakes a 733 KB GeoJSON feed. The times are rough. Lower is better.
| SOML for AppleScript | NSJSONSerialization | |
|---|---|---|
Read service |
16 ms | 247 µs |
Read platform |
110 ms | 1.5 ms |
Read earthquakes |
2.9 s | 54 ms |
Write service |
14 ms | 265 µs |
Write platform |
112 ms | 1.7 ms |
Write earthquakes |
7.9 s | 60 ms |
NSJSONSerialization is native code with a much smaller grammar, so it is the ceiling, not a competitor. Its times include making AppleScript records and lists. AppleScript has no TOML library in common use.
FIXTURE is service, platform, earthquakes, or all (the default). SAMPLES sets the sample count (default: 21 for service, 11 for platform, and 5 for earthquakes).
make test
# Or:
npm testRequires Node.js 26 or later, with no npm dependencies. Runs the conformance suite with Node.js and the API tests in AppleScript. Set SHOW_REASONS=1 to print the error messages that differ from the reference. Use make test NODE=/path/to/node to select a Node.js executable.
To update test/conformance, check out soml next to this repository and run ../soml/sync-conformance.sh test/conformance. To update the benchmark fixtures, run rsync --archive --delete ../soml/benchmark/ bench/fixtures/.
The library is one file, source/SOML.applescript, and its terminology is source/SOML.sdef.