Loom: A Programming Language

I'm a programming language nerd enthusiast, and one of the ways this manifests itself is in the occasional urge to design a new language. There have been multiple such attempts in my past and I succumbed to the urge again last year.

Here's the result. It's called Loom.

The initial implementation is available here, along with a language reference and library reference.

If you want a more gentle introduction to it, read on.

This post is mostly about the ideas behind it but it can also serve as a wierdly overthought tutorial.


Roughly speaking, Loom is a dialect of Smalltalk with C++-style syntax. Its goal is to be:

The core ideas were stolen from Smalltalk when they left the doors unlocked one night while the syntax was accidentally-on-purpose stolen from sclang. I also shoplifted some useful concepts from Ruby, and a few Lisp ideas may also have somehow found their way into my bag.

The three main ideas behind Loom are:

  1. Everything is an object.
  2. Everything is done by sending a message1 to an object.
  3. Both compiling and running Loom code are simple enough processes that you can hold them in your head.

Running stuff

If you clone and (successfully) build the sources linked above, you'll have the Loom interpreter. When run with no arguments, it will drop into an interactive session (aka a REPL):

$ ./src/loom
Loom REPL. Hooray!

> 3 + 4

(It helps to have rlwrap installed; the script in src/loom will use it if it's available.)

You quit it with an EOF character (CTRL+D on *nix).

And if you run it with a Loom program, it will attempt to execute it, as one does:

$ ./src/loom examples/sieve.loom 40
Solving up to 40...
Primes up to 40: 
    2 3 5 7 11 13 17 19 23 29 31 37 

Okay, onward to the language itself. Let's start with some basic stuff:

Basic Stuff

Comments begin with # or // and go to the end of the line:

// comment
# also a comment
2 + 3;  // comment after a statement

(I just couldn't pick a favourite.)

Numbers and strings are as you'd expect from a C-style syntax:

123                     # Decimal integer
420.69                  # Decimal float
0xBADCAFE               # Hex integer
0b1011                  # Binary integer

"Hello!\nworld!\n"      # Some C-style escapes are supported

There's also syntax for creating symbols and vectors. These look like literals but aren't:

[1, 2, 3]               # Vector (i.e. array)
:foo                    # The symbol 'foo'

Symbols represent names within the system, just like in Lisp, Smalltalk, Ruby, and other right-thinking languages.

And Vectors are what I call arrays, because I'm pretentious. And also because I'm reserving the word Array for a possible future type that's more primitive. (Vectors can be resized in place; arrays can't. In the future, I may want to implement Vectors around Array instances so I don't need to use the host system's types. But I digress.)

Names (used for variables and methods) follow the standard C convention:


That is, anything matching the regexp /^[_a-zA-Z][_a-zA-Z0-9]*/.


  1. Any variable name beginning with an upper-case letter is a constant:

    def Pi = 3.14159

    (This also works for method arguments and locals; the latter isn't useful and is kind of a bug.)

  2. There are a few well-known constants (Self, True, False, Nil, and Here) whose lowercase names (self, true, false, etc.) are reserved by the parser and expanded into their upper-case versions. So (e.g.) nil is just another way of writing Nil.

  3. Any character (almost) can be part of a name if it's quoted with backtick characters (`):

    `$20, same as in town` = 20;
    PoliteObject.new.`please initialize this instance`();

This last property leads to some clever hackery we can do with syntax.

Message syntax

Sending a message to an object follows the usual C++-style syntax we know and love:

object.message(arg1, arg2, arg3)

Since this is the only thing you can do, Loom coding (and reading) would normally be a huge slog. We work around this in a number of ways, mostly by fiddling with the syntax.

For example, consider some basic arithmetic:


We can (and do) use backticks and give the methods more operator-like names:


and this is slightly better but still not very readable.

So the parser2 treats any token made of operator characters as implying the `` .`...` `` part. And if there's no open parenthesis token (`(`) afterward, it treats this as equivalent to taking the next token and wrapping the parens around that.

Which means the above can be written as

3 * 4 + 1;
3 + (4) + 1;
3 + 4 + (1);

This also means that Loom doesn't need brackets3. If you need to change the order of evaluation, you can use the argument list parens:

a + (b * c) + (d * e);

// Equivalent to

We do other things with syntax. If a (non-operator) message has no arguments, it's safe to leave off the trailing parens:

b = a.foo;

So getter methods are basically free. For setters, the parser looks for a trailing = token and if it finds it, first renames the message to have a trailing underscore and then passes the expression after the = as its argument. The following are equivalent:

b.foo = bobo.count + 1;
b.foo_(bobo.count() + 1);

Semi-related, C-style array access syntax gets expanded into at and atPut message sends:

x = a[n + 1];
// is equivalent to
x = a.at(n + 1);

x[n + 1] = 42;
// is equivalent to
x.atPut(n + 1, 42);

So vector access looks the way you'd expect it to, but so does the (very slow) Dictionary class. Anything that implements at and atPut can be accessed with this syntax.

Okay, onward to the deep end.


Loom does Lisp-style quoting. You mostly don't need to worry about it unless you're poking around the internals, but as I intend to do just that, this is necessary.

The syntax for a quoted expression is the expression surrounded by special brackets :( and ). For example:

x = :( foo );

Quotes keep the things between the brackets from being evaluated. So in the above snippet, x gets the symbol foo instead of the value of the variable named foo.

(And yes, the :foo syntax above is just shorthand for :(foo).)

Most Loom objects just evaluate to themselves, so quoting them has no effect. The exceptions are symbols (as above), message send expressions, and quoted expressions themselves.

There's one extra bit of quote-related syntax. A Vector expression prefixed with a colon (:[ instead of [) is equivalent to quoting each element of the vector. The following are equivalent:

:[a, b, c]
[:a, :b, :c]
Vector.with(:a, :b, :c)

Quotes end up being vital for a lot of metaprogramming-related things, and since the underlying machinery of Loom is already based on metaprogramming, we need them.

(I initially tried to avoid adding this feature. I thought I could simply decompose each object into an expression that recreated it, so that (e.g.) :foo would expand to "foo".intern. This might be viable, but debugging any kind of metaprogramming was a nightmare problem that Quote mostly removed.)

Objects and Classes

Now, let's talk about object-oriented programming. Here's a class definition:

def ContactInfo = Object.subclass(:[name, address, work_phone,
                                    home_phone, tags]);

Let's start to the left of the =.

The def keyword defines a global constant, ContactInfo and assigns the result of the expression after the = to it. (def is syntax that expands to a call to Here.defglobal(...). I'll get to Here later.)

To the right, we see Object. This is the root class which, like all other classes, is an object. Its method subclass creates the new class and its instance variables (aka slots) are defined by the array of symbols subclass receives as its first argument.

Most classes have an initializer method (constructor in C++-speak):

ContactInfo::initialize = { | name_arg |
    name = name_arg;
    tags = [];

This is an ordinary method but it gets called by the class's instantiation method, new; its arguments (the name(s) between the | characters) are all passed to initialize:

def Ringo = ContactInfo.new("Ringo Starr");

Instance variables are private to the object, so to get at them from outside, we'll need to add a getter and setter method:

ContactInfo::name = { return name };
ContactInfo::name_ = { | new_name | return name = new_name };

(Recall that something like this:

Ringo.name = "Richard Starkey";

gets expanded to a call to name_, the setter.)

Loom actually has built-in shorthand for this (and also the read-only and write-only variants), so you'll rarely need to write them by hand.


Methods can also take variadic arguments:

ContactInfo::tag = {|*all_tags|
  tags = tags + all_tags;
Ringo.tag(:ringo, :the_best_drummer_in_liverpool);

They also (obviously) have local variables, declared between a second, optional pair of pipe (|) characters:

ContactInfo::set_field_count = { ||
    | sum |
    sum = tags.size;
    [name, address, work_phone, home_phone].each{|fld|
        fld.is_nil.not .if { sum = sum + 1 }

    return sum;

In this case, we need to also specify an empty argument list. However, it's safe to omit empty argument lists if the resulting code is unambiguous. (This is any case except for when there are temporaries but no arguments.)

We can also add methods to individual objects:

Ringo::*is_pete_best = { return false };

This includes classes:

ContactInfo::*new_beatle = { return self.new("Paul McCartney") };
def Paul = ContactInfo.new_beatle;

All of this sytax for defining new methods expands into ordinary message send expressions. For example, this

ContactInfo::dial = { ... }

expands into something like this

ContactInfo.inner_add_method(:dial, ...);

So all of this is available for metaprogramming.

Sending Messages

In addition to the language's message-send syntax, message-based languages typically provide a way to programmatically send a message to an object. This is typically done by method(s) of the base class that take the name and message arguments as their own arguments, then send them and return the result. This is how Loom does it as well.

In Loom, there are two methods of class Object: send and sendv.

send is a variadic method whose first argument is the message name (a symbol) and the remaining argument are passed to the message. For example,

3.send(:`+`, 4)             # 7

This is equivalent to either of

3 + 4

But because the message is an argument, we can compute it:

msg = self.select_at_random(:[`+`, `-`, `*`, `/`]);
3.send(msg, 4)              # ???

sendv is like send, but not variadic. Instead, it takes exactly two arguments where the second is a vector containing the message's arguments. With sendv, the above examples would look like this:

3.sendv(:`+`, [4])          # 7

msg = self.select_at_random(:[`+`, `-`, `*`, `/`]);
3.sendv(msg, [4])           # ???

This is important because, while Loom methods can take variadic arguments, there is currently no other way to unpack a vector of arguments into an argument list the way (e.g.) Ruby's * prefix does.

In the future, something like this will probably work

args = [];
// ...append arguments to args...
thing.msg(*args);       // Not implemented yet

but for now, you'll need to use sendv:

args = [];
// ...append arguments to args...
thing.sendv(:msg, args);

The Machinery of Objects and Classes

Under the hood, the Loom object system is actually (crudely) prototype-based, by which I mean that 1) objects have their own method dictionaries and 2) can delegate method lookup to one or more other objects.

In practice, it isn't a very good prototype system, but there's enough there to use as the basis for a powerful class-based object system.

The core idea behind this is that we have a special kind of object called a trait. Traits are ordinary objects with the usual method dictionary (and delegate list), but they also have a second method dictionary/delegate list pair. (We call these inner methods and delegates.)

If an object has a trait as a delegate, the trait's inner dictionary (and inner delegate list) will be used instead of the usual (outer) one.

This gives us the foundation for classes and the rest is just library code implementing common-sense conventions. In Loom, a class is just a trait that:

  1. Provides the method new (to create new instances).
  2. Provides a method named slots that returns the list of instance variables.
  3. Provides the method subclass to create a subclass.
  4. Is part of the common class heirarchy rooted at Object, using its first inner delegate slot as the superclass.

Items 1, 2 and 3 are provided by the metaclass Class, which serves as the class of all named classes (including Class itself) and item 4 is de-facto enforced by method subclass since all objects that provide it are already in the heirarchy.

Traits also give us mixins (which I call AddonTraits for dumb reasons):

def Boopable = AddonTrait.new;
Boopable::boop = { "Booped.".println };

def BoopableContact = Contact.subclass([], Boopable);

BoopableContact.new("George Harrison").boop;

These can be mixed into new classes by passing them to subclass after the slot list.

Blocks and Control Flow

Loom, like Smalltalk, has easy lambdas (called blocks here4), and as in Smalltalk and Lisp, they're used for flow control.

(By lambda, I mean an anonymous function that has access to the (possibly local) scope in it was defined.)

You normally define a block with braces, just like method bodies, and you invoke it with the call method:

blk = {"***block body***".println};
blk.call();             # "***block body***"

Blocks can (but don't have to) take arguments and define local variables:

add = {|a, b| |result| result = a + b; result};
add.call(3, 4);         # 7

And they capture their local context:

Thing::counter = {||
    total = 0;
    return { total = total + 1; total }

def x = Thing.new.counter;
x.call;               # 1
x.call;               # 2
x.call;               # 3
x.call;               # 4

If you're familiar with Lisp, Ruby, or Smalltalk, this is old hat to you. (If not and I just blew your mind, feel free to take a moment.)

Loom uses blocks for nearly all flow control. For example, the if statement is implemented by adding methods to the Boolean types:

def Boolean = Object.subclass([]);
def True = Boolean.new;
def False = Booelan.new;

True::*if = {|body| return body.call()};
False::*if = {|body| return false};

Since all boolean operations return True or False, something like this

a > b .if { "a is bigger!".println };

works as expected. If a > b returns True, it will invoke True's if and that will evaluate the block. If it returns False, it will instead return False's if, which does not.

Short-circuited AND and OR operations work in much the same way:

a > b && { self.is_really_better(a, b) } .if { self.do_thing(a) };

(Aside: the parser will treat one or more blocks following an ordinary message send as arguments for that message. So the following are equivalent:

a.b({1}, {2});
a.b({1}) {2};
a.b() {1} {2};
a.b {1} {2};

Which can make the code look a bit cleaner. In the case of the && operator, normal parsing rules apply; there's an implicit pair of parents around the first block.)

The foreach loop's equivalent is provided by the Vector method each (by way of a mixin named Enumerable):

[1,2,3,4,5].each{|n| n.str + "," .print }   # 1,2,3,4,5

We also have the usual other map/reduce/etc methods:

[1,2,3,4,5].map{|n| n*n}                    # [1, 4, 9, 16, 25]
[1,2,3,4,5].select{|n| n*2 > 4}             # [3, 4, 5]
[1,2,3,4,5].inject(0) {|sum, n| sum + n}    # 15

And the for loop's equivalent is the same thing, but over an object (class Range) that pretends to be an array of increasing integers:

1 -> 5.each{|n| n.str + " " .print }
1 2 3 4 5

And the typical while loop is just as easy. All it needs is... um...

Okay, fine, while is a built-in method of Block written in C++.

You call it like this:

{n < 5} .while { n = n + 1 ; n.str + " " .print }

How Methods Work

As mentioned above, the brace-delimited function syntax ({ ... }) is syntactic sugar expanded by the parser into a set of message sends. It makes some sense to think of it as a fancier form of the quoted array expression. That is, something like this

{ a + 1; b + 2 }

expands to something a lot like the expansion of

:[ a + 1, b + 2 ]

(This is before we talk about the arguments and local variables, of course. Also, the parser treats semicolons as separators but will forgive extras more easily.)

The missing piece of this is what happens when you quote a Loom message send:

:( a + 1 )              # a.+(1)
:( a + 1 ).class        # MsgExpr

That's right, there's a class representing a message send expression. It looks like this:

def MsgExpr = Object.subclass(:[
    receiver,       # The expression to the left of the "."
    message,        # The message, a symbol
    args            # The vector of argument expressions

A method body is just an array of these (or symbols, or other objects), and a trivial Loom interpreter looks something like this:

Evaluator::eval_obj = { | context, obj |
    obj.class == MsgExpr .if { ||
        | receiver, args |
        receiver = self.eval_obj(context, obj.receiver);
        args = args.map{|arg| self.eval_obj(context, arg) };
        return receiver.send(obj.message, args);
    obj.class == Symbol .if { return context.lookup_name(obj) };
    return obj;

Evaluator::eval_method_body = { | context, method_body |
    method_body.each{|expr| self.eval_obj(context, expr) };
    return context.lookup(:Self);

There are more fiddly little details to it than that, but this is the core idea.

If you quote a block definition,

:( {2+3} )

you'll get something like this:

ProtoMethod.new([], nil, [], :[2.+(3)], nil).make_block(Here)

Which is to say that you're getting a little bit more than just a list of expressions. Block (and method) definitions expand into an instance of class ProtoMethod, which looks like this:

def ProtoMethod = Object.subclass(:[
    args,       # Vector of formal arguments
    restvar,    # nil or the name of the variadic argument list
    locals,     # Vector of local variable names
    body,       # Vector of expressions that make up the method body
    annotation  # nil or a descriptive string intended for error messages

The first three arguments get filled from the argument and local variables list and the fourth is the actual method body.

This could be interpreted as a method or block by something like the Evaluator example above. However, in the actual implemention, methods and blocks are opaque internal C++ structures that are easy to access from the actual (C++) evaluator. ProtoMethod serves as the intermediate step. Actual methods are created by a pair of build-in methods, make_block and make_method. This is analogous to how Lisp's lambda converts several lists into a callable function.

So there's nothing stopping you from constructing a ProtoMethod.new(...) expression programmatically and turning it into an executable object.

(You can also get just the ProtoMethod by prefixing your Block declaration with a colon:

:{2+3}          # ProtoMethod([],nil,[],[2.+(3)],nil

This is occasionally useful.)

There's one subtle gotcha here, though. Loom requires you to declare variables before you use them. This is a guard against typo-based bugs and also makes the scopes of names unambiguous.

So this method definition will result in an error:

def Thing = Object.subclass([])
Thing::bar = { return some_undefined_variable }

But this one won't:

Thing::foo = { |a| a .if { return another_undefined_variable } }

The reason for this, if you think about it for a few moments5 is pretty clear. The inner block expands into an expression like ProtoMethod.new(...).make_block(...). That is, not a function, but an expression that will create the function. So the method doesn't touch any undefined variables at all. It's only when it gets run and tries to define the block that it does something wrong. Which is, of course, far too late for our purposes.

And because the whole thing is just done with ordinary(ish) objects and methods, it's not like I'll always be able to guarantee the name correctness of a block or method. So I've kind of painted myself into a corner, haven't I?

Well, not really. Every brace expression gets expanded into something static enough that it's relatively straightforward to search it for undefined names6. So this is what we do.

If you're doing something clever with ProtoMethods like creating them programmatically, the system (probably) won't help you, but at that point, undefined names are the least of your worries. For ordinary blocks and methods, the Loom will give you a warning (upgradeable to error) if you get a name wrong.

Here, or How Variable Assignment Works

The thing I've mostly skirted around so far is how variable assignment works in Loom. You'll recall that <reverb>Everything Is Done With Message Sends</reverb>. Most things are easy enough to do that way, but variables aren't objects so you can't send them messages.

In Smalltalk (and Lisp), variable assignment is one of the few things that still needs to be done by its own top-level thing instead of calling a method or function. Finding a way to do this was a shower problem for me for a while, and when I hit on this idea, it was enough to inspire me to actually build a language around it.

It goes like this:

Each context has a local constant named Here (aliased to here) that references the context itself7. Here's class (Context) provides methods to access its names or those of outer scopes according to the expected scoping rules. The method set does the latter.

The parser simply expands conventional variable assignments into here.set(...) message sends:

foo = bar + 1;                  // This...
here.set(:foo, bar + 1);        // ...becomes this.

here.set follows the same scoping rules that the evaluator uses when looking up variables (current block, outer blocks, method, object, and global) and stores the value in the appropriate namespace and slot.

As with blocks, this means that you can defeat the compile-time checks for undefined names if you're overly clever:

here.set("unknown_" + "variable" .intern, 42)

And that's fine. The name checking really only cares about likely accidents, which means the boring infix-style assignment you get from the syntactic sugar. That's where the name typos you don't expect will come from.

But having here as the way to access your local scope gives you all kinds of extra flexibilty. Consider this little debug printf method you can monkeypatch onto Context:

Context::pvar = {|name|
    self.has(name) .if {
        name.str + "=" + (self.get(name).str) .println

Now if you want to print a variable, you can just do

Bar::do_thing = {
    // ...
    // ...

and you'll get a nicely-formatted message.

Odds and Ends

How return works

The final bit of syntactic magic is the return statement. Like everything else, it's syntactic sugar wrapping a message send. Specifically, it invokes Context::return.

Here's a typical method with a return statement:

Bar::thing = { |a| return a + 1 }

The return a + 1 part expands to:

here.method_scope.return(a + 1);

method_scope returns the Context belonging to the current method call8. This is important because we expect return to operate at the method level:

ContactInfo::dial = {
    self.location.time_of_day < (Time.noon) .if { return nil };
    return self.really_dial;

That is, we expect the return after the if to cause dial to return before the next expression (calling really_dial). If return nil had expanded to here.return(nil), it would only have exited from the block itself and not the method.

This mechanism can be (ab)used in clever ways. For example, this method

Thing::quux = {
            return "nope";        // skipped
        return "also nope";       // skipped
    return "Yup";                 // run

will return the string "Yup because the innermost return will cause the outer two blocks to also exit and let control flow fall to the next statement.

Doing stuff like this is generally a bad idea, but it illustrates how powerful Context::return can be. Future versions of Loom may add extra control statements (e.g. break and continue) built on this stuff.

Exceptions and Ensure

Loom also has exceptions. They got added late to the process, just because it made it so much easier to write tests for failing conditions.

Initially, Loom had a Context method named fail, which quit the program with a message. That worked well enough for a while, but the tests got increasingly awkward so I added catchable exceptions.

Here's an example:

    here.throw("Some error")
}.catch(String) {|e| 
    "Caught exception '" + e + "'" .println;

And it does pretty much what you expect. Block::catch is like call, except that if Context::throw is called with an object whose class matches9 catch's first argument, calls its second argument with it and execution continues from there on.

It probably would have been possible to write this in pure Loom using Context::return, but currently it's just two native C++ functions with about 25 lines of code.

In an earlier draft of this post, the next couple of paragraphs talked about how this exception system was pretty weak overall. The underlying problem is that there's no way to guarantee that cleanup code will run after an exception the way Java does with finally or Ruby does with ensure, leading to all kinds of hard-to-track-down errors.

But then, I asked myself how hard it would really be to just fix that rather than document the failings. So I tried it, and it took maybe half a day to implement.

Here's an example of the feature:

    fh = File.new(filename);
}.callAndEnsure {

The block argument (in this case, containing fh.close()) is always called after the receiving block exits, regardless of how. It can throw an exception or do a return or just run to the end.

You can also combine it with exception catching, as one does:

    fh = File.new(filename);
}.catchAndEnsure(ProcessingException) { |e|
    return nil;
} {

Both of these methods are written in pure Loom, by the way. The undelying machinery is provided by built-in method Context::ensure. This takes a block and evaluates it just before the context returns.

Here's the source code for catchAndEnsure to illustrate this:

Block::catchAndEnsure = {|klass, handler, ensure_block|
    return self.catch(klass, handler);

The ensure block gets attached to the method's here instead of the call to self, but that's good enough. After self.catch(...) exits, here will also always return so ensure_block will also be evaluated.

Bypassing Overridden Methods (i.e. super)

I ended up writing a lot of Loom code before the first time I needed to be able to call a superclass's version of a method the current object had overridden. Which surprised me; I'd assumed that I'd need it much sooner than that10.

But I did need it, and it was unexpectedly tricky to figure out how to do it without any magic.

tl; dr, I ended up adding it Context as a method named super_send.

This works just like self.send but the method search starts at the superclass of the class that defined this method. (Not self; it's possible that it's already inherited this method, so the method determines the starting point.)

Here's an example:

Thing::blatt = {|x| return here.super_send(:blatt, x); }

And, symmetrically with Object::send, there's a sendv version:

Thing::blatt = {|x| return here.super_sendv(:blatt, [x]); }

The reason it belongs to Context is because at the time of the super_send call, here is the only well-known object that knows both self and current method.

Final Thoughts

Loom is the first language I've designed that I actually want to use.

Most of my experiments in language design are successful in that they produce a result, but that result has usually been, That wasn't a good idea after all. Loom didn't do that.

The tooling is awful, libraries are nonexistant, and the whole thing runs at geological speeds. And yet, it's fun to write Loom code. Writing runtime code was almost always easier in Loom than in C++. This despite the fact that I have an extremely good C++ toolchain with astoundingly good debugger support.

It's been fun even when I did really complex things. Figuring out if a Block uses an undefined name was difficult and required a lot of thought and iterative design, but doing that in Loom was not only possible but easier.

So that's how I'll end it. Loom doesn't suck. I'm as surprised by this as you are.

  1. If you're unfamiliar with the Smalltalk concept of sending a message to an object, just mentally replace the term with calling a method. That's close enough for our purposes. 

  2. Calling it a parser is perhaps overly generous, but it's the thing that turns text into internal data structures, so there we go. 

  3. I had planned to add brackets and full BEDMAS infix evaluation order rules, but I found that just this and the other little bits of syntax were enough. 

  4. This term was also stolen from Smalltalk. 

  5. This is totally something I saw coming from the start and didn't catch me by surprise long after the basic Loom system was up and running. 

  6. Fun fact: the code that does this is written in Loom itself. It also serves as an optimizer because it will evaluate the ProtoMethod.new(...) expressions ahead of time. 

  7. Smalltalk also has Here (named thisContext, though) but doesn't take the next step of using it for variable assignment. 

  8. Or nil, if there isn't one. That's not really possible on the current C++ implementation, though, since each input expression gets turned into its own wierd mutant unnamed method. 

  9. By which I mean, is an instance of the class passed to catch or one of its subclasses. 

  10. You would think that decades of writing OOP code would have been the hint, but nope. 

#   Posted 2023-11-19 20:25:49 UTC; last changed 2023-11-19 21:35:40 UTC