As mentioned, we've reached another point at which I haven't yet reduced the interpreter to "a simple matter of coding," and I'm going to discuss how to get the environment code ready for user-defined symbols. I don't yet know "the right answer" in this area, as will be clear, so this is essentially the student thinking through the assignment. And of course I'm focused on simple implementation, not theoretically perfect semantics, but we would like the result not to be intolerably icky.
Note that throughout I assume that Nil is a lisp-1 with a single namespace, like Scheme, and not a lisp-2 with separate function and variable namespaces like, I gather, Common Lisp. (Though as Nil is case-sensitive one can easily get the effect of two namespace just by following my C/C++ rule that functions begin with a capital and variables begin with a minuscule--I haven't done this for the system functions yet, but am tempted.) Single-namespace is, at the very least, much cleaner (for example, we should only need one 'define' or 'def' function as in Scheme, not separate 'setq' and 'defun' style as I see in Common Lisp code (worded slightly evasively because it's quite likely I don't really understand what CL is doing). Also note that I'm assuming dynamic scoping for now, as it should be simpler for an interpreter. Dynamic scoping works just fine for toy languages and more, even though there is no question that lexical scoping is superior from an engineering point of view.
The simplest thing that could possibly work is to simply have a global environment variable. The downside to this is that all scoping would have to be done manually, by pushing and popping contexts onto the head of a list (the environment is already a list of contexts). While we could have a language with every variable being otherwise global (that is, nothing is ever popped off and symbols are simply re-defined rather than shadowed), I think it would be intolerable for function parameters to interact that way (I think this would take us back to the state of the art in maybe the '40s or '50s) even in a toy language. At the least Apply should push a context which maps the formal parameters to the actual parameters, call the function, and then pop the context again. This really isn't too bad, as Apply is a special magic routine inside the interpreter and basically would do this in any scenario.
More interesting is what happens with user definitions. Suppose Apply works as described above. Interestingly, if def simply pushes a symbol onto the current context, we get local variables for free. A little too local, in fact--the obvious code would have def create a new symbol in its context, which would get popped when it returns (because of course calling def involves a recursive call to Apply). def would effectively be a no-op as far as external code is concerned! So def, at least, needs access to the enclosing context to do anything. But that isn't really a problem--def can simply walk back one context on the list and push it's definition there. Unless I've missed something, that appears to be a workable system.
Can we make it better? One thing that won't work is to instead pass the environment as a parameter on the system stack (which is actually what I'm doing now). That would pop the context automatically on function exit, but that's the problem--def can't walk back on the stack because it can't alter the pointer in its caller. However, what might work is passing in a pointer to the list pointer, so that def can modify its caller's context but they still eventually get popped automatically. There are likely pitfalls there I have not considered, so the matter bears more thought. For example, if I were to actually implement garbage collection (right now Nil leaks just as fast as it can allocate cons cells), how would having context pointers on the C stack work?
As noted, keeping everything in one global environment seems to be the simplest, and I'm not sure why I'm reluctant to just settle on that.
Dustin