A Reconstruction of TRAC Programming Language


TRAC (not to be confused with Trac, an issue-tracking system for software projects) is a small macro language first implemented in 1964. It never gained too much traction since its designer, Calvin Mooers, trademarked the name and that curbed the potential popularity. You would think that with such a simple syntax and semantics it would be a common discussion topic among the current hipster software developer geek zeitgeist alongside with Forth and LISP (which is, admittedly, getting less and less hip by the minute), but it seems like rarely anyone had even heard of it.

I encountered the language while reading Etudes for Programmers (by Charles Wetherell) the other day and I saw that the "TRAC algorithm" is only 10 steps. I thought to myself, that's a language I haven't heard of and it looked simple enough to be a fun little exercise, I might as well give it a try - and so I did, and there's slightly more than I thought to this tiny language.

The processing algorithm

The processing algorithm below is the equivalent to the lexing, parsing and evaluating process of other languages (e.g. LISP variants). Both the original version ("original", i.e. as described in []) and the Etudes for Programmers version contains a lot of GOTO so I prepared the following version which I believe is easier to express in modern structural programming constructs.

  1. Prepare the following:
    • Two string buffers for a "neutral" string and an "active" string.
    • A stack of lists for storing locations of start & ending of function names and their arguments. All the locations would be locations in the neutral string buffer. We'll refer to this stack as "the calling stack".
  2. Initialize the neutral string buffer with an empty string and the active string buffer with an "idling procedure" string. This string is often some variants of #(ps,#(rs)) which reads a string up till a certain meta character (with #(rs)), "evaluates" it and prints it (with ps). This idling procedure corresponds to what's known today as a REPL. How this idling procedure works is explained later.
  3. Starting from the left-end of the active string, check the beginning of the string:
    • If there isn't any character for checking (i.e. the active string is empty), initialize both the neutral string buffer and the active string buffer as in step 2, i.e. clears the neutral string buffer and load the idling procedure into the active string buffer. Restart step 3.
    • Or, if the first character is a left parenthesis ( , find the matching right parenthesis ).
      • If such a matching right parenthesis cannot be found, discard everything in neutral and active string buffer, initialize both as in step 2, then restart step 3. (It seems like one should make a syntax error report here, but this is not mentioned in both Etude for Programmers and Mooers's paper, so I'll leave it out here as well.)
      • Or, if such a right parenthesis can indeed be found, the contents between the left and this right parenthesis (not including the parentheses) is inserted to the right-end of neutral string buffer. Restart step 3 from the next character of this right parenthesis.
    • Or, if the beginning of the active string is a hash followed by a left parenthesis #( , this indicates the beginning of an active function call. Register a new active function call to the calling stack with a mark of the length of the current neutral string; since we'll be inserting the rest (i.e. the function name and arguments) into the right-end of neutral string later, the current length of the neutral string would become the location of the beginning of the function name in the neutral string. Remove #( from the active string and restart step 3.
    • Or, if the beginning of the active string is two hashes followed by a left parenthesis ##( , this indicates the beginning of a neutral function call. Register a new neutral function call to the calling stack with a mark of the length of the current neutral string. Remove the ##( part from the active string and restart step 3.
    • Or, if the first character is a comma , , the actions taken depend on if there's a function call registered already.
      • If there is not, remove the comma from the active string, add it to the right-end of neutral string, and restart step 3.
      • If there is, then it shows that an argument for the latest registered function call is ending. Mark the current length of neutral string as the end of the last argument. Restart step 3.
    • Or, if the first character is a right parenthesis ) , check if there's a function call already registered.
      • If there is not, remove the parenthesis from the active string and add it to the neutral string.
      • If there is, then:

        1. Retrieve the latest registered function call with the recorded marks and extract the function name and all the arguments with it from the neutral string.
        2. Remove this function call from the stack.
        3. Delete everything in the neutral string buffer after the beginning of the latest registered function call.
        4. Perform the operation accordingly; but if the call is to a name that's not a built-in function, do nothing (maybe a name error should be reported here).

        Then, depending on whether (1) it returns a null value (i.e. empty string), (2) it's an active function call or a neutral function call and (3) the latest registered function call is to a built-in function name:

        • If it returns null, then no operation is done.
        • If it's a call to a function name that's not built-in, treat it as if it returns null, i.e. no opertion is done.
        • If it's an active function call, the nominated result of the operation is inserted to the left-end of the active string; i.e. the result of an active function call immediately gets processed next.
        • If it's a neutral function call, the nominated result of the operation is appended to the right-end of the neutral string.

        After all this is done, remove the function call from the stack and restart step 3.

    • Or, if none of the rules mentioned above applies, remove the first character from the active string and add it to the neutral string. Repeat step 3.

The "meta character" mentioned above in step 2 is to denote the end of an input for rs; the meta character is set to an apostrophe ' by default, and can be set to other characters by calling the built-in cm function, which sets the meta character to the first character of its first argument. This means that rs could read multi-line strings, provided the user does not set the meta character to newline. When a function is called the arity is not checked: excessive arguments are ignored and missing arguments are treated as empty strings (e.g. #(ps) prints an empty string).

From the process above we can know the following things:

  • Parentheses () are being used as some kind of "quote"s (as in quote in LISPs).
  • Active function calls are evaluated repeatedly till they cannot be further processed, while neutral function call are only evaluated "once".

This algorithm is only one half of TRAC; the other half is the way TRAC implements text macros, which will be explained later.

Examples

Now we'll look at how this algorithm works on some TRAC programs. Using the idling procedure #(ps, #(rs)) as an example:

  • The substring #( is found and removed. A function call is thus pushed onto the stack with the current length of neutral string (0).
  • The substring ps is added to the neutral string.
  • The substring , is removed. The position of end of the function name (2) is registered in the latest function call.
  • The substring #( is found and removed. A new function call is pushed onto the stack with the current length of neutral string (2).
  • The substring rs is added to the neutral string.
  • The substring ) is found and removed. Since there are registered function calls, it signifies the end of the latest one. One could retrieve the function name with the registered mark (2) and the current length of neutral string (4).
  • One retrieves neutral[2~4] and obtain rs. This function reads a string from the user and returns it as the result value.
  • Since it's registered as an active function call, this string the program read from the user is added to the left-end of the active string.
  • The user's input is thus processed next. Normally the final result would end up at the right-end of neutral string.
  • The substring ) is found and removed. We can retrieve neutral[0~2] and obtain ps, which prints its argument. The final result (already appended to the neutral string at this point) is thus printed.
  • The active string is empty, thus the whole process starts again.

The difference between parentheses quoting, active function call and neutral function call can be demonstrated by considering the evaluation process of #(ps,(#(cl,BB))), #(ps,##(cl,BB)) and #(ps,#(cl,BB)), where:

  • cl "calls" a string, i.e. replace the call with the definition of its argument;
  • BB is defined to be #(cl,AA) by executing #(ds,BB,(#(cl,AA))) beforehand where ds means "define string";
  • AA is in term defined to be CAT by executing #(ds,AA,CAT) beforehand.

The difference when evaluating is thus as follows:

  • After , is found and removed in the first program, a left parenthesis is found; this leads to the whole #(cl,BB) part to be directly appended to the neutral string, thus the program prints #(cl,BB).
  • After , is found and removed in the second program, a neutral function call is found; this leads to #(cl,BB) to be evaluated, which gives #(cl,AA); this result is appended to the neutral string, thus the program prints #(cl,AA).
  • After , is found and removed in the third program, an active function call is found; this leads to the result of #(cl,BB), i.e. #(cl,AA), to be put into the active string and it itself got evaluated; this gives us CAT which is later appended to the neutral string, thus the program prints CAT.

Text macro in TRAC

We have seen a few built-in functions already:

  • ps prints the string to the output;
  • rs reads a string from the input;
  • ds (in the form of #(ds,NAME,STR) "define"s a string, which stores a string (STR) under a certain given name (NAME). The defined string was called a "form" in proper TRAC terminology, but it's basically the "declaration + assignment" function call. Since functions in TRAC is defined as macros this doubles as function declaration & definition as well.
    • Two extra function, #(dd,NAME) (dd stands for "delete definition") and #(da) (da stands for "delete all") is used to remove previous definitions.
  • cl "call"s a string; depending on whether it's a neutral call or an active call, the string referred to can be evaluated or appended to the neutral string.

The macro construction is done with the ss (stand for "segment string") function call. It takes an indefinite amount of arguments, i.e. its call has the form of #(ss,NAME,PARAM1,PARAM2,...). When the function is called, TRAC finds every occurence of PARAM1,PARAM2,... in the string stored under the name NAME (by using ds) and replace them with a "gap". When this string is called (by using cl), one can supply strings to insert into these gaps. For example:

#(ds,STRING,THE QUICK BROWN FOX JUMPS OVER THE LAZY DOG)'
#(ss,STRING,DOG,FOX)'

This turns the string STRING into the following form, with [1] and [2] denoting the gaps that would get filled in when being called with cl:

THE QUICK BROWN [2] JUMPS OVER THE LAZY [1]

After the last call to ss, the following call:

#(cl,STRING,,WHAT)'
#(cl,STRING,WHAT,)'
#(cl,STRING,TURTLE,FISH)'
#(cl,STRING,CAT,RAT)'

…would return the following strings, respectively:

THE QUICK BROWN WHAT JUMPS OVER THE LAZY
THE QUICK BROWN JUMPS OVER THE LAZY WHAT
THE QUICK BROWN FISH JUMPS OVER THE LAZY TURTLE
THE QUICK BROWN RAT JUMPS OVER THE LAZY CAT

ss together with ds and cl can form the basis for Markov algorithm, TRAC is thus Turing-complete.

Form pointer and partial calling

Defined strings in TRAC is slightly more than a simple string; each form has a form pointer which upon definition points to the first character in the form. TRAC provides a set of "partial call" functions that works with form pointers and returns a substring of any specified form:

  • #(cr,NAME): stands for "call restore". This restores the form pointer of NAME to the beginning.
  • #(cc,NAME,Z): stands for "call character". If NAME is previously defined, returns the character under the form pointer of NAME; or else, returns empty string. The form pointer will be moved one character to the right, skipping any segment gaps. If the form pointer is already at the right-most position (i.e. the end), Z is returned instead.
  • #(cs,NAME,Z): stands for "call segment". Same as cc, but returns the substring starting from the form pointer to the beginning of the next segment gap instead. The form pointer is moved to the position after said segment gap.
  • #(cn,NAME,N,Z): stands for "call N". Same as cc, but instead of returning only one character, it returns N characters. When N is not specified it's considered to be zero by default, thus it will returns an empty string. When the form pointer is near the end and there aren't as many as N characters left, all the remaining characters is returned and no further operation is done, meaning that this time the return value would be a substring shorter than N characters.
  • #(in,NAME,S,Z): stands for "initial". A pattern (S) is searched for in the form NAME, and if a match is found, returns the substring starting from the current form pointer to the beginning of the match, and move the form pointer to the position after the end of the match. If no match is found (including the case where the form pointer is at the end of NAME), Z is returned instead. Matches cannot contain any segment gaps, e.g. A[]B (where [] represents a segment gap) does not match with AB.

Non-text examples

In order for it to be practical to use, a few numerical and I/O primitives are added to TRAC as well. For example, factorial in TRAC can be defined as follows; in this example, eq is the "if-equal-then-else" construct, ml is multiplication and su is subtraction:

#(ds,Factorial,(#(eq,X,1,1,(#(ml,X,#(cl,Factorial,#(su,X,1)))))))'
#(ss,Factorial,X)'
#(cl,Factorial,5)'

Due to how the processing algorithm works, the program above can also be written as:

#(cl,Factorial,5
#(ds,Factorial,(
#(eq,X,1,
1,
(#(ml,X,#(cl,Factorial,#(su,X,1)))))))
#(ss,Factorial,X))'

One may notice that there seems to be a worrying lack of indentation; this is because in TRAC while tabs and newlines (including carriage return and line feed) is ignored, spaces are not ignored, even though leaving extra spaces in for formatting purposes isn't likely to affect things that aren't related text manipulation.

Deviation of T84 from T64

The version of TRAC described above is dubbed "T64"; a newer version of TRAC, dubbed "T84", was created in 1984. Although Mooers originally trademarked TRAC so that he could have full control of what the language would look like, the deviation between T84 and T64 was large enough that one could deem this attempt useless and hold the opinion that the author should just let people freely create new spins upon it simply because things were going to change anyway. The main differences are listed as follows:

  • The character which is used to designate a function call is changed from a hash # to a colon :.
  • The difference between neutral call and active call is removed; all function calls are neutral calls by default. A suffix system is used to fill in the missing semantics caused by removing active calls (explained later).
  • A few built-in functions's names are changed, e.g. rs is now i (for "input"), ds is now s (for "store") and ps is now o (for "output").
  • A z-return parameter is added to certain primitive functions; when the main operation of the function failed (e.g. opening a non-existent file), the string passed as the z-return parameter is interpreted. This provides some form of error handling functionality.
  • The way text macro is done is different (described below).
  • A new kind of form is added called the "node form". This is kind of similar as dictionaries in Python, tables in Lua and other similar constructs in other languages: nodes can contain strings or other nodes, and nodes that contains sub-nodes can be manipulated as a whole. Node forms needs to have a name that ends with a period .. The manual of the official implementation of T84 (published by TRAC Foundation) shows that

Suffix system of TRAC T84

Suffixes are things you put after certain function name to change its behaviour of certain aspects. Three kinds of suffixes exist in T84: d, i and <j>. d and i are supported by any primitive that returns a value: when added after the function name, d discards the nominated return value as if the function was supposed to return null in the first place; i "interprets" the returned value as source code, which corresponds to the active function call in T64. For example, :(i) (#(rs) in T64) reads the input and returns it; :(ii) reads the input and evaluates it before returning it (making :(i) the equivalent to ##(rs) in T64 and :(ii) the equivalent to #(rs) in T64); :(id) reads the input, discards it and returns an empty string instead.

The j in <j> suffixes should be a number; this suffix is used to specify he "channel number" (think of it like file handles) and is supported by certain I/O and file primitives.

Text macros in TRAC T84

In T64, macros are defined with a combination of ds and ss; in T84, this is done with a single function call to mm or a combination of s (which is equivalent to ds in T64) and mw (which takes only one argument and has no equivalent in T64). The slots in T84 macros are written as numbers (between 0 to 126) surrounded by angle brackets <>. When evaluated, slot 0 is replaced with the name of the macro (chosen when calling mm or s) by default and the argument replacing starts with slot 1. T84 (at least the implementation from TRAC Foundation) macros support comments (surrounded by <* and *>) as well.

Implementations

A few people have made their own version already; my attempt can be found here. I don't think I'll work on TRAC language per se; when I was implementing T64 I could already found a few things that I don't like. If one day I decided to come back to this I'll probably create my own "TRAC dialect".

References

  1. Mooers, C. N.: TRAC, a procedure-describing language for the reactive typewriter. Commun. ACM 9, 3 (March 1966), 215–219. https://doi.org/10.1145/365230.365270
  2. Wetherell, C.: Etudes for Programmers. Prentice-Hall (1978).
  3. Mooers, C. N.: Definition and Standard for TRAC T-64 Language. (1972). [Retrieved through Wayback Machine. https://web.archive.org/web/20040531054816/http://tracfoundation.org/trac64/docs/T64definition.pdf]
  4. Mooers, C. N.: The Beginner's Manual for TRAC Language. (1972). [Retrieved through Wayback Machine. https://web.archive.org/web/20050214150205/http://tracfoundation.org/trac64/T64manual.htm]
  5. Mooers, C. N.: TRAC T84 Manual. (1994) [Retrieved through Wayback Machine. https://web.archive.org/web/20040305115650/http://www.tracfoundation.org/trac84/tracmanual/TMtoc.htm]

Disclaimer

Just in case (definitely don't want any potential lawsuit): "TRAC" is a trademark originally hold by TRAC Foundation, Inc..


Back

Last update: 2024.1.7