McCarthy's LISP

Python 3, 770 bytes

This is a REPL on stdin/stdout. Expects every line to be a full statement or empty. eval is used to shorten the implementation, but is otherwise not necessary for logic.

import re,sys;S=re.sub
P=lambda l:eval(S("([A-Z0-9][A-Z0-9 ]*)",r"' '.join('\1'.strip().split())",S("NIL","()",S("\)",",)",l))))
d={"QUOTE":'(v,L[1])[1]',"EQ":'[(),"T"][E(L[1],v)==E(L[2],v)]',
"CDR":'E(L[1],v)[1:]',"CONS":'(E(L[1],v),)+E(L[2],v)',"CAR":'E(L[1],v)[0]',
"LAMBDA":'("#",)+L[1:]',"LABEL":'[v.update({L[1]:E(L[2],v)}),L[1]][1]'}
def E(L,v):
 if L*0=="":return v[L]
 elif L[0]in d:return eval(d[L[0]])
 elif L[0]=="COND":return next(E(l[1],v)for l in L[1:]if E(l[0],v)=="T")
 elif L[0]=="ATOM":o=E(L[1],v);return[(),"T"][o*0in["",o]]
 else:l=E(L[0],v);n=v.copy();n.update({a:E(p,v)for a,p in zip(l[1],L[1:])});return E(l[2],n)
R=lambda o:o==()and"NIL"or 0*o==()and"(%s)"%", ".join(R(e)for e in o)or o
g={}
for l in sys.stdin:
 if l.strip():print(R(E(P(l),g)))

APL (Dyalog Unicode), 523...458 451 bytes

Saved 12 bytes thanks to @Bubbler!

Saved 1 byte thanks to @Adám (and 7 more thanks to this comment on another answer)

s←⎕SE.Dyalog.Utils.repObj
z←'NIL'
p←{')'=⊃⍵:(⊂z)(1↓⍵)⋄v r←{'('=⊃⍵:p⍵⋄⍵(↑,Ö⊂↓)⍨⊃⍸⍵∊',)'}1↓⍵⋄n r←∇r⋄(n,⍨⊂v)r}
e←{1=≡⍵:((⊃⍺)⍳⊂⍵)⊃(1⊃⍺),⊂⍵⋄⍺(⍎⍺e⊃⍵)1↓⍵}
b←⊃∘z'T'
CAR←⊃e∘⊃
CDR←1↓e∘⊃
QUOTE←⊃⊢
CONS←{(⊂⍺e⊃⍵),⊆⍺e 1⊃⍵}
EQ←{b≡/⍺∘e¨¯1↓⍵}
ATOM←b 1=∘≡e∘⊃
COND←{z≢⍺e⊃⊃⍵:⍺e 1⊃⊃⍵⋄⍺∇1↓⍵}
LAMBDA←{⍕'{((('(s⊃⍵)'),⊃⍺)((⍺∘e¨⍵),1⊃⍺))e'(s 1⊃⍵)'}'}
LABEL←{u←⍎⍕'#.'(⊃⍵)'←',⍺e 1⊃⍵⋄⊃⍵}

To execute a line of lisp:

{{1=≢⍵:⍵⋄1=≡⍵:'_'⎕R' '⊢⍵⋄⍕1⌽')(',∇¨¯1↓⍵}⍬⍬e⊃p'(?<=\(|,) *(?=\d)| +' ' *([(),]) *'⎕R'_' '\1'⊢⍵}

Try it online!

Unfortunately, this doesn't work in TIO dzaima (and Adám) got ⎕SE.Dyalog.Utils.repObj working! Requires ⎕IO←0 now.

Formatting the input

First ⎕R is used to format the input. It accepts a bunch of regex on the left and the strings to replace them with on the right. In this case, we're replacing areas before atom names starting with digits ((?<=\(|,) *(?=\d)) or multiple spaces ( +) with a single underscore because in APL, identifiers cannot contain spaces or start with digits.

At the same time, *([(),]) * captures parentheses or commas along with spaces around them, but only keeps the parentheses and commas, dropping leading or trailing spaces by doing so. Thus, ( QUOTE, (1, ATOM 2)) would become (QUOTE,(_1,ATOM_2)).

Creating an AST

p creates an AST from this formatted string. Every atom is represented by a string, and every s-expression is simply an array of atoms or other s-expressions, with the string NIL at the end. Thus, (QUOTE,(ATOM_1,ATOM_2)) would become ('QUOTE' ('ATOM_1' 'ATOM_2' 'NIL') 'NIL').

Executing the AST

e takes such an AST as its right argument and evaluates it. The left argument is a context that gives the variables in scope. It's an array whose first elements is a list of strings denoting the names of atoms, while the second element is a list of the same length with those variables' values. For example, given a lisp expression ((LAMBDA, (X, Y), (CONS, Y, X)), (QUOTE, (ATOM 1)), (QUOTE, ATOM 2)), the context would look like this (the NILs are an unfortunate side effect of how the ASTs are structured, but they're harmless):

       'X'     |    'Y'   | 'NIL'
'ATOM_1' 'NIL' | 'ATOM_2' | 'NIL'

If the right argument to e is a single string (an atom), it looks up the atom in the context and gets back the corresponding value. If the atom is not found in the context, it just gives back the same string. So in the example above, if (the right argument) were X, we would get back ATOM_1, but if it were CAR, we would get back CAR again.

Otherwise, it's a function call, so it calls e on the first element of that tree with the same context on the left. The first element of the AST is either a label name or one of the nine "builtin" functions, or a lambda expression. If it's the former, we'll just get back the name of the function again. In that case, we can execute the string with (APL's equivalent of eval) to get another dyadic function. We give it the same context on the left and apply it to the rest of , the right argument.

Otherwise, if it's a lambda expression, applying the function LAMBDA (the one I've defined) returns a string containing a function, so again, we can execute it and apply it to the rest of .

The initial left argument is ⍬⍬ ( is an empty array), because we don't have any variables defined yet.

Printing the result

{1=≢⍵:⍵⋄1=≡⍵:'_'⎕R' '⊢⍵⋄⍕'('(∇¨¯1↓⍵)')'} is a recursive function that turns arrays into strings. 1=≢⍵ checks if the argument is a string (with depth 1). If so, it turns underscores back into spaces with '_'⎕R' '. Otherwise, it drops the last element (always NIL) using ¯1↓⍵, then calls itself on each element using ∇¨. That is put into an array with parentheses, and is used to turn that array into a string (with extra spaces).

LAMBDA

This function returns a string representing another function. For example, (LAMBDA, (X, Y), (CONS, X, Y)) becomes

{((('X' 'Y' 'NIL'),⊃⍺)((⍺∘e¨⍵),1⊃⍺))e'CONS' 'X' 'Y' 'NIL'}

While the definition of LAMBDA itself may look messy, there is nothing complicated about it. On the right is simply the body given to the lambda; only the context has changed. Like the Python answer, I used dynamic scoping, so the new context adds to the context given at the call site, not where the lambda is defined. The first element is just the names lambda's parameters prepended to the the old context's names. The second is the result of evaluating all the arguments (with the same context), prepended to the old context's values. repObj (assigned to s because it is used multiple times) finds the string representation of the parameters and lambda body.

LABEL

This is why we had to add underscores before. LABEL constructs a string that looks like #.name ← lambda, given the arguments name lambda 'NIL', executes it, adding a function called name to the root namespace #, so it is global. Like LAMBDA, it ignores the context given to it, because of dynamic scoping.