Implement unwinding mechanism, repl example
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
;; vi:ft=lisp:sw=2:ts=2
|
||||
|
||||
(print (length *args*))
|
||||
(print *args*)
|
||||
@@ -0,0 +1,36 @@
|
||||
;; vi:ft=lisp:sw=2:ts=2
|
||||
|
||||
(defun cadr (x) (car (cdr x)))
|
||||
|
||||
(defun map-ok-err (f-ok f-err result)
|
||||
(if (= (car result) 'ok)
|
||||
`(ok ,(f-ok (cadr result)))
|
||||
`(err ,(f-err (cadr result)))
|
||||
)
|
||||
)
|
||||
(defun map-ok (f-ok result) (map-ok-err f-ok identity result))
|
||||
(defun map-err (f-err result) (map-ok-err identity f-err result))
|
||||
|
||||
(defun repl-print (value)
|
||||
(print "==>" value)
|
||||
)
|
||||
(defun repl-eval-print (expression)
|
||||
(map-ok-err repl-print repl-eval-error (unquote (eval expression)))
|
||||
)
|
||||
|
||||
(defun repl-eval-error (error)
|
||||
(print "Evaluation error:")
|
||||
(print error)
|
||||
)
|
||||
(defun repl-read-error (error)
|
||||
(print "Parse error:")
|
||||
(print error)
|
||||
)
|
||||
|
||||
(loop
|
||||
(let (expression (read))
|
||||
(if expression NIL (return))
|
||||
(setq expression (unquote expression))
|
||||
(map-ok-err repl-eval-print repl-read-error expression)
|
||||
)
|
||||
)
|
||||
@@ -18,6 +18,7 @@ use crate::{
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct CompilationModule {
|
||||
pub(crate) name: Option<Rc<str>>,
|
||||
pub(crate) constant_pool: Pool<CompileConstant, { ConstantId::BITS }>,
|
||||
pub(crate) local_functions: HashMap<u32, CompiledFunction>,
|
||||
pub(crate) options: CompileOptions,
|
||||
@@ -27,8 +28,9 @@ pub struct CompilationModule {
|
||||
}
|
||||
|
||||
impl CompilationModule {
|
||||
pub fn new(options: CompileOptions) -> Self {
|
||||
pub fn new(name: Option<Rc<str>>, options: CompileOptions) -> Self {
|
||||
Self {
|
||||
name,
|
||||
options,
|
||||
..Default::default()
|
||||
}
|
||||
@@ -70,6 +72,7 @@ impl CompilationModule {
|
||||
// Emit all function code first
|
||||
let mut function_offsets = HashMap::new();
|
||||
let mut instructions = vec![];
|
||||
let name = self.name;
|
||||
let root = self.root.unwrap();
|
||||
for (index, function) in self.local_functions.into_iter() {
|
||||
function_offsets.insert(index, instructions.len());
|
||||
@@ -99,6 +102,7 @@ impl CompilationModule {
|
||||
.collect();
|
||||
|
||||
Ok(Module {
|
||||
name,
|
||||
constants,
|
||||
instructions,
|
||||
entry,
|
||||
|
||||
+21
-4
@@ -30,6 +30,8 @@ enum Error {
|
||||
pub enum Trace {
|
||||
Compile,
|
||||
Execute,
|
||||
Call,
|
||||
Return,
|
||||
}
|
||||
|
||||
impl FromStr for Trace {
|
||||
@@ -39,6 +41,8 @@ impl FromStr for Trace {
|
||||
match s {
|
||||
"compile" => Ok(Self::Compile),
|
||||
"execute" => Ok(Self::Execute),
|
||||
"call" => Ok(Self::Call),
|
||||
"return" => Ok(Self::Return),
|
||||
_ => Err(format!("Unknown trace flag: {s:?}")),
|
||||
}
|
||||
}
|
||||
@@ -53,6 +57,7 @@ struct Args {
|
||||
)]
|
||||
trace: Vec<Trace>,
|
||||
module: Option<PathBuf>,
|
||||
arguments: Vec<String>,
|
||||
}
|
||||
|
||||
fn print_syntax_errors(errors: &[ParseError]) {
|
||||
@@ -84,7 +89,7 @@ fn handle_eval_error(value: Option<&Value>, input: EvalError) -> Error {
|
||||
eprintln!(":: {}", error.error);
|
||||
eprintln!();
|
||||
if let Some(ip) = error.ip.as_ref() {
|
||||
ip.module.dump(Some(ip.address), 8);
|
||||
ip.module.dump(Some(ip.address), 8, 2);
|
||||
}
|
||||
}
|
||||
EvalError::Compile(CompileError::Parse(errors)) => {
|
||||
@@ -119,7 +124,7 @@ fn eval(
|
||||
env: &mut Environment,
|
||||
value: Value,
|
||||
) -> Option<Value> {
|
||||
let result = vm.eval_value(options.clone(), env, value.clone());
|
||||
let result = vm.eval_value(options.clone(), env, value.clone(), false);
|
||||
match result {
|
||||
Ok(r) => Some(r),
|
||||
Err(error) => {
|
||||
@@ -162,14 +167,15 @@ fn run_module<P: AsRef<Path>>(
|
||||
path: P,
|
||||
) -> Result<(), Error> {
|
||||
let path = path.as_ref();
|
||||
let name = format!("{}", path.display());
|
||||
let reader = BufReader::new(File::open(path)?);
|
||||
let module_reader = ModuleReader::new(reader);
|
||||
let module = match module_reader.compile(compile_options, env) {
|
||||
let module = match module_reader.compile(Some(name.into()), compile_options, env) {
|
||||
Ok(module) => module,
|
||||
Err(error) => return Err(handle_module_error(error)),
|
||||
};
|
||||
|
||||
match vm.eval_module(env, module) {
|
||||
match vm.eval_module(env, module, false) {
|
||||
Ok(_) => Ok(()),
|
||||
Err(error) => Err(handle_eval_error(None, error)),
|
||||
}
|
||||
@@ -182,8 +188,19 @@ fn main() -> ExitCode {
|
||||
trace_compile: args.trace.contains(&Trace::Compile),
|
||||
};
|
||||
vm.trace_instructions = args.trace.contains(&Trace::Execute);
|
||||
vm.trace_calls = args.trace.contains(&Trace::Call);
|
||||
vm.trace_returns = args.trace.contains(&Trace::Return);
|
||||
let mut env = Environment::default();
|
||||
prelude::load(&mut env);
|
||||
let mut arguments = vec![];
|
||||
if let Some(script) = args.module.as_ref() {
|
||||
arguments.push(format!("{}", script.display()));
|
||||
}
|
||||
arguments.extend(args.arguments);
|
||||
env.set_global_value(
|
||||
"*args*",
|
||||
Value::list_or_nil(arguments.into_iter().map(|arg| Value::String(arg.into()))),
|
||||
);
|
||||
let result = match args.module.as_ref() {
|
||||
Some(module) => run_module(&compile_options, &mut vm, &mut env, module),
|
||||
None => run_interactive(&compile_options, &mut vm, &mut env),
|
||||
|
||||
+3
-2
@@ -120,7 +120,7 @@ impl<R: BufRead> ModuleReader<R> {
|
||||
let expression = Expression::parse(&value).map_err(Either::Right)?;
|
||||
if let Expression::Defmacro(_) = expression.as_ref() {
|
||||
self.macro_machine
|
||||
.eval_value(options.clone(), env, value)
|
||||
.eval_value(options.clone(), env, value, false)
|
||||
.map_err(Either::Left)?;
|
||||
continue;
|
||||
}
|
||||
@@ -130,10 +130,11 @@ impl<R: BufRead> ModuleReader<R> {
|
||||
|
||||
pub fn compile(
|
||||
mut self,
|
||||
module_name: Option<Rc<str>>,
|
||||
options: &CompileOptions,
|
||||
env: &mut Environment,
|
||||
) -> Result<ModuleRef, Either<EvalError, Vec<ParseError>>> {
|
||||
let mut module = CompilationModule::new(options.clone());
|
||||
let mut module = CompilationModule::new(module_name, options.clone());
|
||||
let mut body = FunctionBody {
|
||||
head: vec![],
|
||||
tail: Rc::new(Expression::Nil),
|
||||
|
||||
+102
-18
@@ -33,6 +33,8 @@ pub struct Machine {
|
||||
value_stack: Stack<Value>,
|
||||
pub call_stack: Stack<CallFrame>,
|
||||
pub trace_instructions: bool,
|
||||
pub trace_calls: bool,
|
||||
pub trace_returns: bool,
|
||||
// Top-level locals
|
||||
locals: HashMap<u32, Value>,
|
||||
}
|
||||
@@ -53,6 +55,8 @@ impl Default for Machine {
|
||||
locals: HashMap::new(),
|
||||
|
||||
trace_instructions: false,
|
||||
trace_calls: false,
|
||||
trace_returns: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -123,10 +127,20 @@ impl Machine {
|
||||
}),
|
||||
locals: HashMap::new(),
|
||||
};
|
||||
let entry_ip = InstructionPointer { module, address };
|
||||
if self.trace_calls {
|
||||
eprintln!("TRACE: Call bytecode function");
|
||||
if let Some(source_ip) = self.ip.as_ref() {
|
||||
eprintln!("TRACE: From {source_ip}");
|
||||
} else {
|
||||
eprintln!("TRACE: From <undefined>");
|
||||
}
|
||||
eprintln!("TRACE: To {entry_ip}");
|
||||
}
|
||||
if self.call_stack.push(frame).is_err() {
|
||||
return Err(self.error_at_ip(MachineErrorKind::CallStackOverflow));
|
||||
}
|
||||
self.ip = Some(InstructionPointer { module, address });
|
||||
self.ip = Some(entry_ip);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -187,10 +201,26 @@ impl Machine {
|
||||
|
||||
fn execute_return(&mut self) -> Result<ExecutionEvent, MachineError> {
|
||||
let ip = self.ip.clone().unwrap();
|
||||
if self.trace_returns {
|
||||
eprintln!("TRACE: Return");
|
||||
eprintln!("TRACE: From {ip}");
|
||||
ip.module.dump(Some(ip.address), 4, 0);
|
||||
}
|
||||
|
||||
if let Some(frame) = self.call_stack.pop() {
|
||||
if self.trace_returns {
|
||||
if let Some(target_ip) = frame.return_address.as_ref() {
|
||||
eprintln!("TRACE: To {target_ip}");
|
||||
} else {
|
||||
eprintln!("TRACE: To <undefined>");
|
||||
}
|
||||
}
|
||||
self.ip = frame.return_address;
|
||||
Ok(frame.event)
|
||||
} else {
|
||||
if self.trace_returns {
|
||||
eprintln!("TRACE: To <undefined>");
|
||||
}
|
||||
self.ip = None;
|
||||
Ok(ExecutionEvent::ModuleExit(ip.module))
|
||||
}
|
||||
@@ -370,6 +400,36 @@ impl Machine {
|
||||
eprintln!();
|
||||
}
|
||||
|
||||
fn unwind(&mut self, until: ExecutionEvent) {
|
||||
if self.trace_returns {
|
||||
eprintln!("TRACE: Begin unwind");
|
||||
if let Some(ip) = self.ip.as_ref() {
|
||||
eprintln!("TRACE: <- {ip}");
|
||||
} else {
|
||||
eprintln!("TRACE: <- <undefined>");
|
||||
}
|
||||
}
|
||||
let mut ip = self.ip.clone();
|
||||
while let Some(frame) = self.call_stack.pop() {
|
||||
if self.trace_returns {
|
||||
eprintln!("TRACE: Unwind frame:");
|
||||
if let Some(ip) = frame.return_address.as_ref() {
|
||||
eprintln!("TRACE: -> {ip}");
|
||||
} else {
|
||||
eprintln!("TRACE: -> <undefined>");
|
||||
}
|
||||
}
|
||||
ip = frame.return_address;
|
||||
if frame.event == until {
|
||||
break;
|
||||
}
|
||||
}
|
||||
self.ip = ip;
|
||||
if self.trace_returns {
|
||||
eprintln!("TRACE: Finished unwind");
|
||||
}
|
||||
}
|
||||
|
||||
pub fn execute_next(
|
||||
&mut self,
|
||||
environment: &mut Environment,
|
||||
@@ -469,23 +529,38 @@ impl Machine {
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
pub fn load_module(&mut self, module: ModuleRef) -> Result<ModuleRef, MachineError> {
|
||||
pub fn load_module(
|
||||
&mut self,
|
||||
module: ModuleRef,
|
||||
advance_on_return: bool,
|
||||
) -> Result<ModuleRef, MachineError> {
|
||||
let entry = module.entry();
|
||||
let entry_ip = InstructionPointer {
|
||||
module: module.clone(),
|
||||
address: entry,
|
||||
};
|
||||
if let Some(ip) = self.ip.clone()
|
||||
&& self
|
||||
.call_stack
|
||||
.push(CallFrame {
|
||||
arguments: vec![],
|
||||
return_address: Some(ip),
|
||||
event: ExecutionEvent::ModuleExit(module.clone()),
|
||||
locals: HashMap::new(),
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
|
||||
let entry_frame = CallFrame {
|
||||
arguments: vec![],
|
||||
event: ExecutionEvent::ModuleExit(module.clone()),
|
||||
locals: HashMap::new(),
|
||||
return_address: self.ip.clone().map(|ip| InstructionPointer {
|
||||
module: ip.module,
|
||||
address: ip.address + advance_on_return as usize,
|
||||
}),
|
||||
};
|
||||
|
||||
if self.trace_calls {
|
||||
eprintln!("TRACE: Enter module");
|
||||
if let Some(source_ip) = self.ip.as_ref() {
|
||||
eprintln!("TRACE: From {source_ip}");
|
||||
} else {
|
||||
eprintln!("TRACE: From <undefined>");
|
||||
}
|
||||
eprintln!("TRACE: To {entry_ip}");
|
||||
}
|
||||
|
||||
if self.call_stack.push(entry_frame).is_err() {
|
||||
return Err(self.error_at_ip(MachineErrorKind::CallStackOverflow));
|
||||
}
|
||||
self.ip = Some(entry_ip);
|
||||
@@ -496,8 +571,9 @@ impl Machine {
|
||||
&mut self,
|
||||
environment: &mut Environment,
|
||||
module: ModuleRef,
|
||||
advance_on_return: bool,
|
||||
) -> Result<Value, EvalError> {
|
||||
let module = match self.load_module(module) {
|
||||
let module = match self.load_module(module, advance_on_return) {
|
||||
Ok(module) => module,
|
||||
Err(error) => return Err(EvalError::Machine(error)),
|
||||
};
|
||||
@@ -505,7 +581,10 @@ impl Machine {
|
||||
loop {
|
||||
let event = match self.execute_next(environment) {
|
||||
Ok(event) => event,
|
||||
Err(error) => return Err(EvalError::Machine(error)),
|
||||
Err(error) => {
|
||||
self.unwind(expect);
|
||||
return Err(EvalError::Machine(error));
|
||||
}
|
||||
};
|
||||
if event == expect {
|
||||
break;
|
||||
@@ -520,17 +599,22 @@ impl Machine {
|
||||
compile_options: CompileOptions,
|
||||
environment: &mut Environment,
|
||||
value: Value,
|
||||
advance_on_return: bool,
|
||||
) -> Result<Value, EvalError> {
|
||||
let value = value.macro_expand(self, environment, false)?;
|
||||
let module = Module::compile_value(compile_options, &value)?;
|
||||
let module = ModuleRef::from(module);
|
||||
self.eval_module(environment, module)
|
||||
self.eval_module(environment, module, advance_on_return)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for InstructionPointer {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{:p}:{}", self.module, self.address)
|
||||
if let Some(name) = self.module.name.as_ref() {
|
||||
write!(f, "<{} {:p}>:{}", name, self.module, self.address)
|
||||
} else {
|
||||
write!(f, "<unnamed {:p}>:{}", self.module, self.address)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -568,7 +652,7 @@ mod tests {
|
||||
build(i as u32, &mut builder);
|
||||
builder.add(Instruction::Return);
|
||||
let module = builder.build();
|
||||
values.push(machine.eval_module(&mut env, module.into()).unwrap());
|
||||
values.push(machine.eval_module(&mut env, module.into(), false).unwrap());
|
||||
}
|
||||
(machine, values)
|
||||
}
|
||||
|
||||
+6
-3
@@ -41,6 +41,7 @@ impl Hash for ModuleRef {
|
||||
impl Eq for ModuleRef {}
|
||||
|
||||
pub struct Module {
|
||||
pub name: Option<Rc<str>>,
|
||||
pub constants: HashMap<ConstantId, ModuleConstant>,
|
||||
pub instructions: Vec<u32>,
|
||||
pub entry: usize,
|
||||
@@ -97,6 +98,7 @@ impl Deref for ModuleRef {
|
||||
impl Module {
|
||||
pub fn dummy() -> Self {
|
||||
Self {
|
||||
name: None,
|
||||
constants: HashMap::new(),
|
||||
instructions: vec![0],
|
||||
entry: 0,
|
||||
@@ -127,7 +129,7 @@ impl Module {
|
||||
|
||||
pub fn compile_value(options: CompileOptions, value: &Value) -> Result<Self, CompileError> {
|
||||
let expression = Expression::parse(value).map_err(CompileError::Parse)?;
|
||||
let mut module = CompilationModule::new(options);
|
||||
let mut module = CompilationModule::new(None, options);
|
||||
module.compile_function(
|
||||
FunctionSignature::EMPTY,
|
||||
&FunctionBody {
|
||||
@@ -139,10 +141,10 @@ impl Module {
|
||||
module.compile_module()
|
||||
}
|
||||
|
||||
pub fn dump(&self, highlight: Option<usize>, context: usize) {
|
||||
pub fn dump(&self, highlight: Option<usize>, context_backward: usize, context_forward: usize) {
|
||||
let window = highlight
|
||||
.map(|end| (end + 1).min(self.instructions.len()))
|
||||
.map(|end| end.saturating_sub(context)..end)
|
||||
.map(|end| end.saturating_sub(context_backward)..end.saturating_add(context_forward))
|
||||
.unwrap_or(0..self.instructions.len());
|
||||
|
||||
let start = window.start;
|
||||
@@ -178,6 +180,7 @@ impl ModuleBuilder {
|
||||
|
||||
pub fn build(self) -> Module {
|
||||
Module {
|
||||
name: None,
|
||||
constants: self.constants.into_map(),
|
||||
instructions: self.instructions,
|
||||
entry: self.entry.unwrap(),
|
||||
|
||||
@@ -200,6 +200,7 @@ pub(crate) fn builtin_cmp(
|
||||
(Value::Integer(a), Value::Integer(b)) => Ord::cmp(a, b),
|
||||
(Value::Boolean(a), Value::Boolean(b)) => Ord::cmp(a, b),
|
||||
(Value::String(a), Value::String(b)) => Ord::cmp(a, b),
|
||||
(Value::Identifier(a), Value::Identifier(b)) => Ord::cmp(a, b),
|
||||
_ => Ordering::Less,
|
||||
}
|
||||
}
|
||||
|
||||
+70
-3
@@ -2,6 +2,7 @@ use std::{rc::Rc, slice};
|
||||
|
||||
use crate::{
|
||||
error::MachineErrorKind,
|
||||
read::{self, InteractiveReader},
|
||||
util::IteratorExt,
|
||||
vm::{
|
||||
env::Environment,
|
||||
@@ -52,6 +53,24 @@ pub fn load(env: &mut Environment) {
|
||||
});
|
||||
|
||||
// lists
|
||||
env.defun_native("car", |vm, _env, args| {
|
||||
let [x] = args else {
|
||||
return Err(vm.error_at_ip(MachineErrorKind::InvalidArgument));
|
||||
};
|
||||
let Value::Cons(cons) = x else {
|
||||
return Err(vm.error_at_ip(MachineErrorKind::InvalidArgument));
|
||||
};
|
||||
Ok(cons.0.clone())
|
||||
});
|
||||
env.defun_native("cdr", |vm, _env, args| {
|
||||
let [x] = args else {
|
||||
return Err(vm.error_at_ip(MachineErrorKind::InvalidArgument));
|
||||
};
|
||||
let Value::Cons(cons) = x else {
|
||||
return Err(vm.error_at_ip(MachineErrorKind::InvalidArgument));
|
||||
};
|
||||
Ok(cons.1.clone())
|
||||
});
|
||||
env.defun_native("cons", |vm, _env, args| {
|
||||
let [car, cdr] = args else {
|
||||
return Err(vm.error_at_ip(MachineErrorKind::InvalidArgument));
|
||||
@@ -85,6 +104,23 @@ pub fn load(env: &mut Environment) {
|
||||
let out = Value::list_or_nil(args.iter().cloned());
|
||||
Ok(out)
|
||||
});
|
||||
env.defun_native("length", |vm, _, args| {
|
||||
let [xs] = args else {
|
||||
return Err(vm.error_at_ip(MachineErrorKind::InvalidArgument));
|
||||
};
|
||||
|
||||
let mut xs = xs;
|
||||
let mut count = 0;
|
||||
while !xs.is_nil() {
|
||||
let Value::Cons(cons) = xs else {
|
||||
break;
|
||||
};
|
||||
let ConsCell(_, cdr) = cons.as_ref();
|
||||
count += 1;
|
||||
xs = cdr;
|
||||
}
|
||||
Ok(Value::Integer(count))
|
||||
});
|
||||
|
||||
// functional
|
||||
env.defun_native("identity", |vm, _, args| {
|
||||
@@ -102,9 +138,15 @@ pub fn load(env: &mut Environment) {
|
||||
[_, _] => todo!(),
|
||||
_ => todo!(),
|
||||
};
|
||||
let value = match vm.eval_value(Default::default(), env, value.clone()) {
|
||||
Ok(result) => result,
|
||||
_ => todo!(),
|
||||
let value = match vm.eval_value(Default::default(), env, value.clone(), true) {
|
||||
Ok(result) => Value::Quote(Rc::new(Value::list_or_nil([
|
||||
Value::Identifier("ok".into()),
|
||||
result,
|
||||
]))),
|
||||
Err(error) => Value::Quote(Rc::new(Value::list_or_nil([
|
||||
Value::Identifier("err".into()),
|
||||
Value::String(format!("{error}").into()),
|
||||
]))),
|
||||
};
|
||||
Ok(value)
|
||||
});
|
||||
@@ -121,6 +163,31 @@ pub fn load(env: &mut Environment) {
|
||||
});
|
||||
|
||||
// io
|
||||
env.defun_native("read", |vm, env, _args| {
|
||||
let mut reader = InteractiveReader::new("> ", ">> ");
|
||||
let value = read::read(&mut reader, vm, env);
|
||||
let value = match value {
|
||||
Ok(Some(value)) => Value::Quote(Rc::new(Value::list_or_nil([
|
||||
Value::Identifier("ok".into()),
|
||||
value,
|
||||
]))),
|
||||
Ok(None) => Value::Nil,
|
||||
Err(error) => Value::Quote(Rc::new(Value::list_or_nil([
|
||||
Value::Identifier("err".into()),
|
||||
Value::String(format!("{error}").into()),
|
||||
]))),
|
||||
};
|
||||
Ok(value)
|
||||
});
|
||||
env.defun_native("unquote", |vm, _env, args| {
|
||||
let [x] = args else {
|
||||
return Err(vm.error_at_ip(MachineErrorKind::InvalidArgument));
|
||||
};
|
||||
let Value::Quote(value) = x else {
|
||||
return Err(vm.error_at_ip(MachineErrorKind::InvalidArgument));
|
||||
};
|
||||
Ok(value.as_ref().clone())
|
||||
});
|
||||
env.defun_native("print", |_, _, args| {
|
||||
for (i, arg) in args.iter().enumerate() {
|
||||
if i != 0 {
|
||||
|
||||
@@ -30,7 +30,7 @@ fn eval_str_in(code: &str, env: &mut Environment) -> Result<Value, EvalError> {
|
||||
Err(error) => panic!("{error}"),
|
||||
};
|
||||
|
||||
last_value = Some(machine.eval_value(Default::default(), env, value));
|
||||
last_value = Some(machine.eval_value(Default::default(), env, value, false));
|
||||
}
|
||||
|
||||
last_value.expect("no expressions evaluated")
|
||||
|
||||
Reference in New Issue
Block a user