use crate::project;
use rustyline::completion::{Completer, Pair};
use rustyline::error::ReadlineError;
use rustyline::highlight::Highlighter;
use rustyline::hint::{Hinter, HistoryHinter};
use rustyline::history::DefaultHistory;
use rustyline::validate::Validator;
use rustyline::{CompletionType, Config, Context, Editor, Helper};
use serde_json::Value as JsonValue;
use std::borrow::Cow;
use std::collections::{BTreeMap, BTreeSet};
use std::env;
use std::fmt::Write as _;
use std::io::{self, BufRead, IsTerminal};
use std::path::{Path, PathBuf};
use tess::ast::{NumericLiteral, TypeRef};
use tess::compiler::normalize_name;
use tess::engine::{Evaluation, Input, Query, evaluate_query};
use tess::value::{Value, ValueKind};
use tess::{
CompiledProgram, Diagnostic, SourceFile, compile_project_with_dependency_graph,
parse_numeric_literal,
};
const COMMAND_COMPLETIONS: &[(&str, &str)] = &[
(":test", ":test "),
(":let", ":let "),
(":set", ":set "),
(":unset", ":unset "),
(":explain", ":explain "),
(":show", ":show"),
(":reset", ":reset"),
(":reload", ":reload"),
(":help", ":help"),
(":quit", ":quit"),
];
const MAX_QUERY_COMPLETIONS: usize = 128;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum ExitStatus {
Success,
LanguageFailure,
IoFailure,
}
struct LoadedProgram {
source: SourceFile,
program: CompiledProgram,
diagnostics: Vec<Diagnostic>,
}
enum LoadFailure {
Io(String),
Language {
source: SourceFile,
diagnostics: Vec<Diagnostic>,
},
}
pub(crate) fn run(target: &Path) -> ExitStatus {
let loaded = match load(target) {
Ok(loaded) => loaded,
Err(LoadFailure::Io(message)) => {
eprintln!("error: {message}");
return ExitStatus::IoFailure;
}
Err(LoadFailure::Language {
source,
diagnostics,
}) => {
render_diagnostics(&diagnostics, &source);
return ExitStatus::LanguageFailure;
}
};
render_diagnostics(&loaded.diagnostics, &loaded.source);
let (mut session, startup) = match Session::new(target.to_path_buf(), loaded) {
Ok(value) => value,
Err(diagnostics) => {
render_diagnostics(&diagnostics, &SourceFile::new("<repl>", ""));
return ExitStatus::LanguageFailure;
}
};
let banner = format!(
"Tess REPL ({}) — type `help`; Tab completes and cycles suggestions\n{startup}{}",
session.loaded.program.ast().module.value,
session.suggestion_text()
);
if let Err(error) = write_stdout(&banner) {
eprintln!("error: could not write stdout: {error}");
return ExitStatus::IoFailure;
}
if io::stdin().is_terminal() {
run_terminal(&mut session)
} else {
run_piped(&mut session)
}
}
fn load(target: &Path) -> Result<LoadedProgram, LoadFailure> {
let target = project::resolve_target(target).map_err(LoadFailure::Io)?;
let project =
compile_project_with_dependency_graph(&target.entry, &target.root, &target.dependencies)
.map_err(|error| LoadFailure::Io(error.to_string()))?;
match project.compile.program {
Some(program) => Ok(LoadedProgram {
source: project.source,
program,
diagnostics: project.compile.diagnostics,
}),
None => Err(LoadFailure::Language {
source: project.source,
diagnostics: project.compile.diagnostics,
}),
}
}
fn run_terminal(session: &mut Session) -> ExitStatus {
let config = Config::builder()
.completion_type(CompletionType::Circular)
.history_ignore_space(true)
.auto_add_history(true)
.build();
let mut editor = match Editor::<ReplHelper, DefaultHistory>::with_config(config) {
Ok(editor) => editor,
Err(error) => {
eprintln!("error: could not start line editor: {error}");
return ExitStatus::IoFailure;
}
};
editor.set_helper(Some(ReplHelper::new(session.completions())));
let mut completion_revision = session.completion_revision();
let history_path = history_path();
if let Some(path) = &history_path {
let _ = editor.load_history(path);
}
let status = loop {
if completion_revision != session.completion_revision() {
if let Some(helper) = editor.helper_mut() {
helper.completions = session.completions();
}
completion_revision = session.completion_revision();
}
match editor.readline("tess> ") {
Ok(line) => match process_line(session, &line) {
Ok(quit) => {
if quit {
break ExitStatus::Success;
}
}
Err(error) => {
eprintln!("error: {error}");
}
},
Err(ReadlineError::Interrupted) => {
let _ = write_stdout("^C\n");
}
Err(ReadlineError::Eof) => break ExitStatus::Success,
Err(error) => {
eprintln!("error: could not read input: {error}");
break ExitStatus::IoFailure;
}
}
};
if let Some(path) = history_path {
if let Err(error) = editor.save_history(&path) {
eprintln!(
"warning: could not save REPL history to `{}`: {error}",
path.display()
);
}
}
status
}
fn run_piped(session: &mut Session) -> ExitStatus {
let stdin = io::stdin();
let mut failed = false;
for line in stdin.lock().lines() {
let line = match line {
Ok(line) => line,
Err(error) => {
eprintln!("error: could not read stdin: {error}");
return ExitStatus::IoFailure;
}
};
match process_line(session, &line) {
Ok(true) => break,
Ok(false) => {}
Err(error) => {
eprintln!("error: {error}");
failed = true;
}
}
}
if failed {
ExitStatus::LanguageFailure
} else {
ExitStatus::Success
}
}
fn process_line(session: &mut Session, line: &str) -> Result<bool, String> {
let line = line.trim();
if line.is_empty() {
return Ok(false);
}
match session.execute(line)? {
CommandOutcome::Continue(output) => {
write_stdout(&output).map_err(|error| format!("could not write stdout: {error}"))?;
Ok(false)
}
CommandOutcome::Quit => Ok(true),
}
}
fn write_stdout(text: &str) -> io::Result<()> {
crate::write_stdout(text)
}
fn render_diagnostics(diagnostics: &[Diagnostic], source: &SourceFile) {
for diagnostic in diagnostics {
eprint!("{}", diagnostic.render(source));
}
}
fn history_path() -> Option<PathBuf> {
env::var_os("TESS_HISTORY")
.map(PathBuf::from)
.or_else(|| env::var_os("HOME").map(|home| PathBuf::from(home).join(".tess_history")))
}
enum CommandOutcome {
Continue(String),
Quit,
}
struct Session {
target: PathBuf,
loaded: LoadedProgram,
input: Input,
case_name: Option<String>,
case_baseline: Option<Input>,
dirty: bool,
last_query: Option<Query>,
last_evaluation: Option<Evaluation>,
completion_snapshot: CompletionSnapshot,
completion_revision: u64,
}
impl Session {
fn new(target: PathBuf, loaded: LoadedProgram) -> Result<(Self, String), Vec<Diagnostic>> {
let (input, case_name, startup) = match loaded.program.cases().len() {
0 => (Input::new(), None, "no tests declared\n".to_owned()),
1 => {
let case = loaded.program.cases().values().next().expect("one test");
let input = Input::from_case(&loaded.program, case)?;
let name = case.name.value.clone();
(
input,
Some(name.clone()),
format!("loaded sole test `{name}`\n"),
)
}
count => (Input::new(), None, format!("{count} tests available\n")),
};
let case_baseline = case_name.as_ref().map(|_| input.clone());
let mut session = Self {
target,
loaded,
input,
case_name,
case_baseline,
dirty: false,
last_query: None,
last_evaluation: None,
completion_snapshot: CompletionSnapshot::default(),
completion_revision: 0,
};
session.refresh_completions();
Ok((session, startup))
}
fn execute(&mut self, line: &str) -> Result<CommandOutcome, String> {
match line {
"help" | "?" => return Ok(CommandOutcome::Continue(help_text().to_owned())),
"quit" | "exit" => return Ok(CommandOutcome::Quit),
_ => {}
}
let Some(command) = line.strip_prefix(':') else {
let query = Query::parse(line)?;
let output = self.evaluate(&query, false)?;
self.last_query = Some(query);
return Ok(CommandOutcome::Continue(output));
};
let (name, arguments) = command
.split_once(char::is_whitespace)
.map_or((command, ""), |(name, rest)| (name, rest.trim()));
match name {
"test" => self.select_case(arguments),
"let" => self.create_binding(arguments),
"set" => self.set_field(arguments),
"unset" => self.unset(arguments),
"explain" => self.explain(arguments),
"show" if arguments.is_empty() => Ok(CommandOutcome::Continue(self.show())),
"reset" if arguments.is_empty() => Ok(self.reset()),
"reload" if arguments.is_empty() => self.reload(),
"help" | "?" if arguments.is_empty() => {
Ok(CommandOutcome::Continue(help_text().to_owned()))
}
"quit" | "q" | "exit" if arguments.is_empty() => Ok(CommandOutcome::Quit),
"show" | "reset" | "reload" | "help" | "?" | "quit" | "q" | "exit" => {
Err(format!("command :{name} does not take arguments"))
}
_ => Err(format!("unknown command `:{name}`; use `help` or `:help`")),
}
}
fn select_case(&mut self, name: &str) -> Result<CommandOutcome, String> {
if name.is_empty() {
return Ok(CommandOutcome::Continue(self.list_cases()));
}
let (input, case_name) = {
let case = self
.loaded
.program
.case(name)
.ok_or_else(|| format!("test `{name}` was not found"))?;
let input = Input::from_case(&self.loaded.program, case)
.map_err(|diagnostics| diagnostic_messages(&diagnostics))?;
(input, case.name.value.clone())
};
let discarded = self.dirty;
self.input = input;
self.case_name = Some(case_name.clone());
self.case_baseline = Some(self.input.clone());
self.dirty = false;
self.invalidate_evaluation();
self.refresh_completions();
let prefix = if discarded {
"discarded modified input; "
} else {
""
};
Ok(CommandOutcome::Continue(format!(
"{prefix}loaded test `{case_name}`\n{}",
self.suggestion_text()
)))
}
fn list_cases(&self) -> String {
if self.loaded.program.cases().is_empty() {
return "no tests declared\n".to_owned();
}
let mut output = String::from("tests:\n");
for case in self.loaded.program.cases().values() {
if self.case_name.as_deref() == Some(case.name.value.as_str()) {
let modified = if self.dirty { ", modified" } else { "" };
let _ = writeln!(output, " * {} (current{modified})", case.name.value);
} else {
let _ = writeln!(output, " {}", case.name.value);
}
}
output
}
fn create_binding(&mut self, arguments: &str) -> Result<CommandOutcome, String> {
let mut words = arguments.split_whitespace();
let binding = words
.next()
.ok_or_else(|| "usage: :let <binding> <Record>".to_owned())?;
let entity = words
.next()
.ok_or_else(|| "usage: :let <binding> <Record>".to_owned())?;
if words.next().is_some() {
return Err("usage: :let <binding> <Record>".to_owned());
}
let entity = self
.loaded
.program
.entity(entity)
.ok_or_else(|| format!("record `{entity}` was not found"))?;
let entity_name = entity.name.value.clone();
let binding = normalize_name(binding);
if self.input.bindings.contains_key(&binding) {
return Err(format!(
"binding `{binding}` already exists; remove it with :unset before rebinding"
));
}
self.input.insert(&binding, &entity_name, BTreeMap::new());
self.after_input_change(true);
Ok(CommandOutcome::Continue(format!(
"bound `{binding}` as {}\n{}",
entity_name,
self.suggestion_text()
)))
}
fn set_field(&mut self, arguments: &str) -> Result<CommandOutcome, String> {
let (path, text) = parse_set_arguments(arguments)?;
let (binding_name, field_name) = parse_field_path(path)?;
let binding_name = normalize_name(binding_name);
let binding = self.input.bindings.get(&binding_name).ok_or_else(|| {
format!("binding `{binding_name}` was not found; create it with :let")
})?;
let field = self
.loaded
.program
.field(&binding.entity, field_name)
.cloned()
.ok_or_else(|| {
format!(
"field `{field_name}` was not found on record `{}`",
binding.entity
)
})?;
let value = parse_value(&self.loaded.program, &field.ty, text)?;
let previous = self
.input
.bindings
.get_mut(&binding_name)
.expect("binding was checked")
.fields
.insert(field.name.value.clone(), value.clone());
if previous.as_ref() == Some(&value) {
return Ok(CommandOutcome::Continue(format!(
"{binding_name}.{} = {value} (unchanged)\n",
field.name.value
)));
}
let diagnostics = self.input.validate(&self.loaded.program);
if !diagnostics.is_empty() {
let fields = &mut self
.input
.bindings
.get_mut(&binding_name)
.expect("binding was checked")
.fields;
if let Some(previous) = previous {
fields.insert(field.name.value.clone(), previous);
} else {
fields.remove(&field.name.value);
}
return Err(diagnostic_messages(&diagnostics));
}
self.after_input_change(false);
Ok(CommandOutcome::Continue(format!(
"{binding_name}.{} = {value}\n",
field.name.value
)))
}
fn unset(&mut self, path: &str) -> Result<CommandOutcome, String> {
if path.is_empty() || path.split_whitespace().count() != 1 {
return Err("usage: :unset <binding>[.<field>]".to_owned());
}
if let Some((binding, field)) = path.split_once('.') {
let binding = normalize_name(binding);
let entity = self
.input
.bindings
.get(&binding)
.ok_or_else(|| format!("binding `{binding}` was not found"))?
.entity
.clone();
let field = self
.loaded
.program
.field(&entity, field)
.ok_or_else(|| format!("field `{field}` was not found on record `{entity}`"))?;
let field = field.name.value.clone();
let removed = self
.input
.bindings
.get_mut(&binding)
.expect("binding was checked")
.fields
.remove(&field);
if removed.is_none() {
return Err(format!("`{binding}.{field}` is already unset"));
}
self.after_input_change(false);
Ok(CommandOutcome::Continue(format!(
"unset `{binding}.{field}`\n"
)))
} else {
let binding = normalize_name(path);
if self.input.bindings.remove(&binding).is_none() {
return Err(format!("binding `{binding}` was not found"));
}
self.after_input_change(true);
Ok(CommandOutcome::Continue(format!(
"removed binding `{binding}`\n"
)))
}
}
fn explain(&mut self, text: &str) -> Result<CommandOutcome, String> {
let query = if text.is_empty() {
self.last_query
.clone()
.ok_or_else(|| "usage: :explain <decision(binding, ...)>".to_owned())?
} else {
Query::parse(text)?
};
let output = self.evaluate(&query, true)?;
Ok(CommandOutcome::Continue(output))
}
fn evaluate(&mut self, query: &Query, explain: bool) -> Result<String, String> {
let cache_matches =
self.last_query.as_ref() == Some(query) && self.last_evaluation.is_some();
if !cache_matches {
let evaluation = evaluate_query(&self.loaded.program, &self.input, query)
.map_err(|diagnostic| diagnostic.message)?;
self.last_query = Some(query.clone());
self.last_evaluation = Some(evaluation);
}
let evaluation = self
.last_evaluation
.as_ref()
.expect("a matching evaluation was cached");
Ok(if explain {
crate::explain::render_evaluation(
&self.loaded.program,
&self.input,
evaluation,
self.case_name.as_deref(),
crate::stdout_supports_color(),
)
} else {
evaluation.render_text()
})
}
fn show(&self) -> String {
let mut output = String::new();
if let Some(case) = &self.case_name {
let modified = if self.dirty { " (modified)" } else { "" };
let _ = writeln!(output, "test: {case}{modified}");
}
if self.input.bindings.is_empty() {
output.push_str("no input bindings\n");
return output;
}
for (name, binding) in &self.input.bindings {
let implicit_name = binding
.entity
.rsplit("::")
.next()
.is_some_and(|record| normalize_name(record) == *name);
if implicit_name {
let _ = writeln!(output, "{} {{", binding.entity);
} else {
let _ = writeln!(output, "let {name} = {} {{", binding.entity);
}
if let Some(entity) = self.loaded.program.entity(&binding.entity) {
for field in &entity.fields {
let key = normalize_name(&field.name.value);
if let Some(value) = binding.fields.get(&key) {
let _ = writeln!(output, " {}: {value},", field.name.value);
} else {
let _ = writeln!(output, " {}: <unset>,", field.name.value);
}
}
} else {
for (field, value) in &binding.fields {
let _ = writeln!(output, " {field}: {value},");
}
}
output.push_str("}\n");
}
output
}
fn reset(&mut self) -> CommandOutcome {
let message = if let Some(baseline) = &self.case_baseline {
self.input = baseline.clone();
format!(
"restored test `{}`\n",
self.case_name.as_deref().unwrap_or("<unknown>")
)
} else {
self.input = Input::new();
self.case_name = None;
"cleared current input\n".to_owned()
};
self.dirty = false;
self.invalidate_evaluation();
self.refresh_completions();
CommandOutcome::Continue(format!("{message}{}", self.suggestion_text()))
}
fn reload(&mut self) -> Result<CommandOutcome, String> {
let loaded = load(&self.target).map_err(|failure| match failure {
LoadFailure::Io(message) => message,
LoadFailure::Language {
source,
diagnostics,
} => diagnostics
.iter()
.map(|diagnostic| diagnostic.render(&source))
.collect::<String>(),
})?;
let warnings = loaded
.diagnostics
.iter()
.map(|diagnostic| diagnostic.render(&loaded.source))
.collect::<String>();
let previous_case = self.case_name.clone();
let refreshed_case = previous_case
.as_deref()
.and_then(|case_name| loaded.program.case(case_name))
.map(|case| {
Input::from_case(&loaded.program, case)
.map(|input| (case.name.value.clone(), input))
.map_err(|diagnostics| diagnostic_messages(&diagnostics))
})
.transpose()?;
let previous_input = std::mem::take(&mut self.input);
let previous_dirty = self.dirty;
self.loaded = loaded;
let mut note = String::new();
let previous_valid = previous_input.validate(&self.loaded.program).is_empty();
match (previous_case, refreshed_case) {
(Some(_), Some((case_name, case_input))) if !previous_dirty => {
self.input = case_input.clone();
self.case_name = Some(case_name);
self.case_baseline = Some(case_input);
self.dirty = false;
note.push_str("; refreshed current test");
}
(Some(_), Some((case_name, case_input))) if previous_valid => {
self.input = previous_input;
self.case_name = Some(case_name);
self.case_baseline = Some(case_input);
self.refresh_dirty();
note.push_str("; kept modified input");
}
(Some(_), Some((case_name, case_input))) => {
self.input = case_input.clone();
self.case_name = Some(case_name);
self.case_baseline = Some(case_input);
self.dirty = false;
note.push_str("; reset input from its test because the schema changed");
}
(Some(case_name), None) if previous_valid => {
self.input = previous_input;
self.case_name = None;
self.case_baseline = None;
self.dirty = !self.input.bindings.is_empty();
let _ = write!(
note,
"; kept current input; detached deleted test `{case_name}`"
);
}
(Some(case_name), None) => {
self.input = Input::new();
self.case_name = None;
self.case_baseline = None;
self.dirty = false;
let _ = write!(note, "; cleared input; test `{case_name}` no longer exists");
}
(None, _) if previous_valid => {
self.input = previous_input;
self.case_name = None;
self.case_baseline = None;
self.dirty = previous_dirty;
note.push_str("; kept current input");
}
(None, _) => {
self.input = Input::new();
self.case_name = None;
self.case_baseline = None;
self.dirty = false;
note.push_str("; cleared input because the schema changed");
}
}
self.last_query = None;
self.last_evaluation = None;
self.refresh_completions();
let mut output = warnings;
let _ = writeln!(output, "reloaded {}{note}", self.target.display());
output.push_str(&self.suggestion_text());
Ok(CommandOutcome::Continue(output))
}
fn after_input_change(&mut self, binding_shape_changed: bool) {
self.refresh_dirty();
self.invalidate_evaluation();
if binding_shape_changed {
self.refresh_completions();
}
}
fn refresh_dirty(&mut self) {
self.dirty = self
.case_baseline
.as_ref()
.map_or(!self.input.bindings.is_empty(), |baseline| {
baseline != &self.input
});
}
fn invalidate_evaluation(&mut self) {
self.last_evaluation = None;
}
fn refresh_completions(&mut self) {
self.completion_snapshot = CompletionSnapshot::from_session(self);
self.completion_revision = self.completion_revision.wrapping_add(1);
}
const fn completion_revision(&self) -> u64 {
self.completion_revision
}
fn completions(&self) -> CompletionSnapshot {
self.completion_snapshot.clone()
}
fn suggestion_text(&self) -> String {
const VISIBLE: usize = 3;
let queries = &self.completion_snapshot.queries;
if queries.is_empty() {
if self.case_name.is_none()
&& self.input.bindings.is_empty()
&& !self.completion_snapshot.cases.is_empty()
{
let shown = self
.completion_snapshot
.cases
.iter()
.take(VISIBLE)
.map(|name| format!(":test {name}"))
.collect::<Vec<_>>()
.join(", ");
let remaining = self.completion_snapshot.cases.len().saturating_sub(VISIBLE);
return if remaining == 0 {
format!("try: {shown}\n")
} else {
format!("try: {shown} (+{remaining} more; use :test to list)\n")
};
}
if self.case_name.is_some() || self.completion_snapshot.cases.is_empty() {
return "no runnable query yet; create missing bindings with :let\n".to_owned();
}
return "no runnable query yet; create bindings with :let or load a test with :test\n"
.to_owned();
}
let shown = queries
.iter()
.take(VISIBLE)
.map(String::as_str)
.collect::<Vec<_>>()
.join(", ");
if queries.len() <= VISIBLE && !self.completion_snapshot.queries_truncated {
format!("try: {shown}\n")
} else {
let remaining = queries.len().saturating_sub(VISIBLE);
let suffix = if self.completion_snapshot.queries_truncated {
format!("showing first {}; press Tab", queries.len())
} else {
format!("+{remaining} more; press Tab")
};
format!("try: {shown} ({suffix})\n")
}
}
}
fn parse_set_arguments(arguments: &str) -> Result<(&str, &str), String> {
let arguments = arguments.trim();
let Some(index) =
arguments.find(|character: char| character.is_whitespace() || character == '=')
else {
return Err("usage: :set <binding>.<field> [=] <value>".to_owned());
};
let path = arguments[..index].trim();
let value = arguments[index..].trim_start();
let value = value.strip_prefix('=').unwrap_or(value).trim_start();
if path.is_empty() || value.is_empty() {
Err("usage: :set <binding>.<field> [=] <value>".to_owned())
} else {
Ok((path, value))
}
}
fn parse_field_path(path: &str) -> Result<(&str, &str), String> {
let (binding, field) = path
.split_once('.')
.ok_or_else(|| "a field must have the form <binding>.<field>".to_owned())?;
if binding.is_empty() || field.is_empty() || field.contains('.') {
Err("a field must have the form <binding>.<field>".to_owned())
} else {
Ok((binding, field))
}
}
fn parse_value(program: &CompiledProgram, ty: &TypeRef, text: &str) -> Result<Value, String> {
match ty {
TypeRef::Bool => match text {
"true" => Ok(Value::Bool(true)),
"false" => Ok(Value::Bool(false)),
_ => Err("expected `true` or `false`".to_owned()),
},
TypeRef::Int => match parse_numeric_literal(text) {
Some(NumericLiteral::Int(value)) => Ok(Value::Int(value)),
Some(NumericLiteral::Decimal(_)) => Err("expected an integer".to_owned()),
None => Err("invalid integer literal or `_` separator placement".to_owned()),
},
TypeRef::Decimal => {
let normalized = match parse_numeric_literal(text) {
Some(NumericLiteral::Int(value)) => value.to_string(),
Some(NumericLiteral::Decimal(value)) => value,
None => {
return Err("invalid decimal literal or `_` separator placement".to_owned());
}
};
Value::from_plain_json(&JsonValue::String(normalized), &ValueKind::Decimal)
.map_err(|error| error.to_string())
}
TypeRef::String => parse_json_value(text, &ValueKind::String),
TypeRef::Date => parse_json_value(text, &ValueKind::Date),
TypeRef::Duration => {
let number = text.strip_suffix('s').unwrap_or(text);
match parse_numeric_literal(number) {
Some(NumericLiteral::Int(value)) => Ok(Value::Duration(value)),
Some(NumericLiteral::Decimal(_)) => {
Err("duration seconds must be an integer".to_owned())
}
None => Err("invalid duration literal or `_` separator placement".to_owned()),
}
}
TypeRef::Named(name) if program.enum_decl(name).is_some() => {
let variant = if text.starts_with('"') {
serde_json::from_str::<String>(text)
.map_err(|error| format!("invalid enum value: {error}"))?
} else {
text.strip_prefix(&format!("{name}::"))
.unwrap_or(text)
.to_owned()
};
if !program.enum_has_variant(name, &variant) {
return Err(format!("`{variant}` is not a variant of enum `{name}`"));
}
Ok(Value::Enum {
type_name: name.clone(),
variant: normalize_name(&variant),
})
}
TypeRef::Named(name) if program.entity(name).is_some() => {
parse_json_value(text, &ValueKind::EntityRef(name.clone()))
}
TypeRef::Named(name) => Err(format!("unknown field type `{name}`")),
TypeRef::Unknown => Err("cannot set a field whose type is unknown".to_owned()),
}
}
fn parse_json_value(text: &str, kind: &ValueKind) -> Result<Value, String> {
let json: JsonValue =
serde_json::from_str(text).map_err(|error| format!("expected JSON literal: {error}"))?;
Value::from_plain_json(&json, kind).map_err(|error| error.to_string())
}
fn diagnostic_messages(diagnostics: &[Diagnostic]) -> String {
diagnostics
.iter()
.map(|diagnostic| diagnostic.message.as_str())
.collect::<Vec<_>>()
.join("; ")
}
fn help_text() -> &'static str {
"\
Enter decision(binding, ...) to evaluate it. Numbers accept `_`; strings and dates are quoted, and durations are seconds with an optional `s`.\n\
Press Tab once to complete the first match; press it repeatedly to cycle through matches. At an empty prompt it cycles runnable decision calls, and after `:` it cycles commands.\n\
\n\
:test [name] list tests or load one\n\
:let <binding> <Record> create an empty record binding\n\
:set <binding>.<field> = <value> set or replace one fact\n\
:unset <binding>[.<field>] remove a fact or binding\n\
:explain [decision(binding, ...)] explain a query (or the previous query)\n\
:show show current input, including unset fields\n\
:reset restore the test or clear ad-hoc input\n\
:reload recompile the target\n\
:help show this help (`help` and `?` also work)\n\
:quit exit (`quit` and `exit` also work)\n"
}
#[derive(Clone, Debug, Default)]
struct CompletionSnapshot {
cases: Vec<String>,
entities: Vec<String>,
queries: Vec<String>,
queries_truncated: bool,
bindings: Vec<String>,
fields: Vec<String>,
values: BTreeMap<String, Vec<String>>,
}
impl CompletionSnapshot {
fn from_session(session: &Session) -> Self {
let program = &session.loaded.program;
let cases = program
.cases()
.values()
.map(|case| case.name.value.clone())
.collect();
let entities = program
.entities()
.values()
.map(|entity| entity.name.value.clone())
.collect();
let bindings = session.input.bindings.keys().cloned().collect::<Vec<_>>();
let mut bindings_by_entity = BTreeMap::<String, Vec<String>>::new();
for (name, binding) in &session.input.bindings {
bindings_by_entity
.entry(normalize_name(&binding.entity))
.or_default()
.push(name.clone());
}
let mut queries = Vec::new();
let mut seen_queries = BTreeSet::new();
let mut queries_truncated = false;
if let Some(case) = session
.case_name
.as_deref()
.and_then(|name| program.case(name))
{
for expectation in &case.expectations {
let arguments = expectation
.arguments
.iter()
.map(|argument| match &argument.kind {
tess::ast::ExprKind::Name(name)
if session.input.bindings.contains_key(&normalize_name(name)) =>
{
Some(normalize_name(name))
}
_ => None,
})
.collect::<Option<Vec<_>>>();
if let Some(arguments) = arguments {
push_query_completion(
Query {
decision: expectation.decision.value.clone(),
arguments,
}
.render(),
&mut queries,
&mut seen_queries,
&mut queries_truncated,
);
}
}
}
for decision in program.decisions().values() {
let choices = decision
.parameters
.iter()
.map(|parameter| {
let mut choices = bindings_by_entity
.get(&normalize_name(¶meter.ty.value))?
.clone();
let preferred = normalize_name(¶meter.name.value);
if let Some(index) = choices.iter().position(|name| *name == preferred) {
let preferred = choices.remove(index);
choices.insert(0, preferred);
}
Some(choices)
})
.collect::<Option<Vec<_>>>();
let Some(choices) = choices else {
continue;
};
append_query_combinations(
&decision.name.value,
&choices,
0,
&mut Vec::new(),
&mut queries,
&mut seen_queries,
&mut queries_truncated,
);
}
let mut fields = Vec::new();
let mut values = BTreeMap::new();
for (binding_name, binding) in &session.input.bindings {
let Some(entity) = program.entity(&binding.entity) else {
continue;
};
for field in &entity.fields {
let path = format!("{binding_name}.{}", field.name.value);
values.insert(path.clone(), value_completions(program, field));
fields.push(path);
}
}
Self {
cases,
entities,
queries,
queries_truncated,
bindings,
fields,
values,
}
}
}
fn append_query_combinations(
decision: &str,
choices: &[Vec<String>],
index: usize,
arguments: &mut Vec<String>,
queries: &mut Vec<String>,
seen: &mut BTreeSet<String>,
truncated: &mut bool,
) {
if *truncated {
return;
}
if index == choices.len() {
push_query_completion(
format!("{decision}({})", arguments.join(", ")),
queries,
seen,
truncated,
);
return;
}
for choice in &choices[index] {
arguments.push(choice.clone());
append_query_combinations(
decision,
choices,
index + 1,
arguments,
queries,
seen,
truncated,
);
arguments.pop();
if *truncated {
break;
}
}
}
fn push_query_completion(
query: String,
queries: &mut Vec<String>,
seen: &mut BTreeSet<String>,
truncated: &mut bool,
) {
if seen.contains(&query) {
return;
}
if queries.len() >= MAX_QUERY_COMPLETIONS {
*truncated = true;
return;
}
seen.insert(query.clone());
queries.push(query);
}
fn value_completions(program: &CompiledProgram, field: &tess::ast::FieldDecl) -> Vec<String> {
if let Some(domain) = &field.domain {
return domain
.values
.iter()
.filter_map(|value| {
program
.source()
.text
.get(value.span.start..value.span.end)
.map(str::trim)
.map(str::to_owned)
})
.collect();
}
match &field.ty {
TypeRef::Bool => vec!["true".to_owned(), "false".to_owned()],
TypeRef::Date => vec!["\"2026-01-01\"".to_owned()],
TypeRef::String | TypeRef::Named(_) => {
if let TypeRef::Named(name) = &field.ty {
if let Some(declaration) = program.enum_decl(name) {
return declaration
.variants
.iter()
.map(|variant| variant.value.clone())
.collect();
}
}
vec!["\"\"".to_owned()]
}
TypeRef::Int | TypeRef::Decimal => field.range.as_ref().map_or_else(Vec::new, |range| {
vec![numeric_literal(&range.start), numeric_literal(&range.end)]
}),
TypeRef::Duration => vec!["0s".to_owned()],
TypeRef::Unknown => Vec::new(),
}
}
fn numeric_literal(value: &NumericLiteral) -> String {
match value {
NumericLiteral::Int(value) => Value::Int(*value).to_string(),
NumericLiteral::Decimal(value) => {
Value::from_plain_json(&JsonValue::String(value.clone()), &ValueKind::Decimal)
.map_or_else(|_| value.clone(), |value| value.to_string())
}
}
}
struct ReplHelper {
completions: CompletionSnapshot,
hinter: HistoryHinter,
color: bool,
}
impl ReplHelper {
fn new(completions: CompletionSnapshot) -> Self {
Self {
completions,
hinter: HistoryHinter::new(),
color: crate::stdout_supports_color(),
}
}
}
impl Helper for ReplHelper {}
impl Validator for ReplHelper {}
impl Hinter for ReplHelper {
type Hint = String;
fn hint(&self, line: &str, pos: usize, context: &Context<'_>) -> Option<Self::Hint> {
self.hinter.hint(line, pos, context)
}
}
impl Highlighter for ReplHelper {
fn highlight_hint<'h>(&self, hint: &'h str) -> Cow<'h, str> {
if self.color {
Cow::Owned(format!("\x1b[2m{hint}\x1b[0m"))
} else {
Cow::Borrowed(hint)
}
}
}
impl Completer for ReplHelper {
type Candidate = Pair;
fn complete(
&self,
line: &str,
pos: usize,
_context: &Context<'_>,
) -> rustyline::Result<(usize, Vec<Self::Candidate>)> {
let before = &line[..pos];
if !before.starts_with(':') {
return Ok(complete_from(0, before, &self.completions.queries));
}
let Some(command_end) = before.find(char::is_whitespace) else {
let pairs = COMMAND_COMPLETIONS
.iter()
.filter(|(display, _)| display.starts_with(before))
.map(|(display, replacement)| Pair {
display: (*display).to_owned(),
replacement: (*replacement).to_owned(),
})
.collect();
return Ok((0, pairs));
};
let command = &before[1..command_end];
let body = &before[command_end..];
if command == "test" {
return Ok(complete_body(before, body, &self.completions.cases));
}
if command == "explain" {
return Ok(complete_body(before, body, &self.completions.queries));
}
if command == "unset" {
let mut candidates = self.completions.fields.clone();
candidates.extend(self.completions.bindings.iter().cloned());
return Ok(complete_body(before, body, &candidates));
}
if command == "let" {
let body = body.trim_start();
if body.find(char::is_whitespace).is_some() {
let start = token_start(before);
return Ok(complete_from(
start,
&before[start..],
&self.completions.entities,
));
}
return Ok((pos, Vec::new()));
}
if command == "set" {
return Ok(self.complete_set(before, body));
}
Ok((pos, Vec::new()))
}
}
impl ReplHelper {
fn complete_set(&self, before: &str, body: &str) -> (usize, Vec<Pair>) {
let body = body.trim_start();
if let Some((path, _)) = body.split_once('=') {
let path = path.trim();
let start = token_start(before);
return complete_from(
start,
&before[start..],
self.completions.values.get(path).map_or(&[], Vec::as_slice),
);
}
if let Some(index) = body.find(char::is_whitespace) {
let path = &body[..index];
let start = token_start(before);
return complete_from(
start,
&before[start..],
self.completions.values.get(path).map_or(&[], Vec::as_slice),
);
}
complete_body(before, body, &self.completions.fields)
}
}
fn complete_body(before: &str, body: &str, candidates: &[String]) -> (usize, Vec<Pair>) {
let leading = body.len() - body.trim_start().len();
let start = before.len() - body.len() + leading;
complete_from(start, &before[start..], candidates)
}
fn token_start(text: &str) -> usize {
text.char_indices()
.rev()
.find(|(_, character)| character.is_whitespace() || *character == '=')
.map_or(0, |(index, character)| index + character.len_utf8())
}
fn complete_from(start: usize, prefix: &str, candidates: &[String]) -> (usize, Vec<Pair>) {
let pairs = candidates
.iter()
.filter(|candidate| candidate.starts_with(prefix))
.map(|candidate| Pair {
display: candidate.clone(),
replacement: candidate.clone(),
})
.collect();
(start, pairs)
}
#[cfg(test)]
mod tests {
use super::*;
use rustyline::history::History;
use tess::compile_source;
const SOURCE: &str = r"mod repl_test
enum Answer:
Yes
No
record Subject:
flag: Bool
score: Int 0..100
answer: Answer
day: Date?
dec outcome(s Subject) -> Answer
rule yes(s Subject):
s.flag => outcome(s) = Yes
rule no(s Subject):
not s.flag => outcome(s) = No
test yes_case:
let s = Subject {
flag: true,
score: 10,
answer: Yes,
}
expect outcome(s) = Yes
";
fn session_from_source(name: &str, text: &str) -> Session {
let source = SourceFile::new(name, text);
let output = compile_source(source.clone());
assert!(output.diagnostics.is_empty(), "{:#?}", output.diagnostics);
let loaded = LoadedProgram {
source,
program: output.program.unwrap(),
diagnostics: Vec::new(),
};
Session::new(PathBuf::from(name), loaded).unwrap().0
}
fn session() -> Session {
session_from_source("repl-test.tes", SOURCE)
}
fn output(outcome: CommandOutcome) -> String {
match outcome {
CommandOutcome::Continue(output) => output,
CommandOutcome::Quit => panic!("unexpected quit"),
}
}
#[test]
fn sole_case_is_loaded_and_fields_can_be_changed() {
let mut session = session();
assert!(output(session.execute("outcome(s)").unwrap()).contains("Answer::Yes"));
let changed = output(session.execute(":set s.flag = false").unwrap());
assert_eq!(changed, "s.flag = false\n");
assert!(output(session.execute("outcome(s)").unwrap()).contains("Answer::No"));
assert!(output(session.execute(":show").unwrap()).contains("yes_case (modified)"));
}
#[test]
fn empty_bindings_can_be_created_and_populated() {
let mut session = session();
output(session.execute(":unset s").unwrap());
output(session.execute(":let order Subject").unwrap());
assert!(session.execute(":let order Subject").is_err());
output(session.execute(":set order.flag true").unwrap());
output(session.execute(":set order.score 9_0").unwrap());
let shown = output(session.execute(":show").unwrap());
assert!(shown.contains("let order = Subject"));
assert!(shown.contains("score: 90"));
assert!(shown.contains("day: <unset>"));
assert!(session.execute(":set order.score 9__0").is_err());
assert!(session.execute(":set order.score 101").is_err());
assert!(output(session.execute(":show").unwrap()).contains("score: 90"));
output(session.execute(":set order.answer No").unwrap());
assert!(output(session.execute(":show").unwrap()).contains("Answer::No"));
}
#[test]
fn show_uses_the_implicit_binding_spelling_when_the_record_names_itself() {
let source = SOURCE.replace(
"let s = Subject {\n flag: true,\n score: 10,\n answer: Yes,\n }\n expect outcome(s) = Yes",
"Subject {\n flag: true,\n score: 10,\n answer: Yes,\n }\n expect outcome(Subject) = Yes",
);
let session = session_from_source("implicit-test-binding.tes", &source);
let shown = session.show();
assert!(shown.contains("\nSubject {"), "{shown}");
assert!(!shown.contains("let Subject = Subject"), "{shown}");
}
#[test]
fn set_parser_keeps_equals_signs_inside_values() {
assert_eq!(
parse_set_arguments(r#"s.note "a=b""#).unwrap(),
("s.note", r#""a=b""#)
);
assert_eq!(
parse_set_arguments(r#"s.note = "a=b""#).unwrap(),
("s.note", r#""a=b""#)
);
}
#[test]
fn completion_covers_commands_cases_fields_and_values() {
let helper = ReplHelper::new(session().completions());
let history = DefaultHistory::new();
assert!(history.is_empty());
let context = Context::new(&history);
let (_, commands) = helper.complete(":te", 3, &context).unwrap();
assert_eq!(commands[0].replacement, ":test ");
let (_, cases) = helper.complete(":test y", 7, &context).unwrap();
assert_eq!(cases[0].replacement, "yes_case");
let (_, fields) = helper.complete(":set s.f", 8, &context).unwrap();
assert_eq!(fields[0].replacement, "s.flag");
let (_, values) = helper.complete(":set s.flag = t", 15, &context).unwrap();
assert_eq!(values[0].replacement, "true");
let enum_line = ":set s.answer = Y";
let (_, values) = helper
.complete(enum_line, enum_line.len(), &context)
.unwrap();
assert_eq!(values[0].replacement, "Yes");
let date_line = ":set s.day = \"2";
let (_, values) = helper
.complete(date_line, date_line.len(), &context)
.unwrap();
assert_eq!(values[0].replacement, "\"2026-01-01\"");
assert!(
parse_value(
&session().loaded.program,
&TypeRef::Date,
&values[0].replacement
)
.is_ok()
);
let (_, queries) = helper.complete("out", 3, &context).unwrap();
assert_eq!(queries[0].replacement, "outcome(s)");
let korean = ReplHelper::new(CompletionSnapshot {
cases: vec!["십만원주문".to_owned()],
..CompletionSnapshot::default()
});
let line = ":test 십";
let (start, cases) = korean.complete(line, line.len(), &context).unwrap();
assert_eq!(start, ":test ".len());
assert_eq!(cases[0].replacement, "십만원주문");
let (_, invalid) = helper.complete(":testfoo y", 10, &context).unwrap();
assert!(invalid.is_empty());
}
#[test]
fn history_hint_color_can_be_disabled() {
let mut helper = ReplHelper::new(session().completions());
helper.color = false;
assert_eq!(helper.highlight_hint(" hint"), " hint");
helper.color = true;
assert_eq!(helper.highlight_hint(" hint"), "\x1b[2m hint\x1b[0m");
}
#[test]
fn help_and_exit_have_shell_friendly_aliases() {
let mut session = session();
assert!(output(session.execute("help").unwrap()).contains(":set"));
assert!(output(session.execute("?").unwrap()).contains("Press Tab once"));
assert!(matches!(
session.execute("exit").unwrap(),
CommandOutcome::Quit
));
assert!(matches!(
session.execute(":q").unwrap(),
CommandOutcome::Quit
));
}
#[test]
fn startup_suggests_runnable_queries() {
let mut session = session();
assert_eq!(session.suggestion_text(), "try: outcome(s)\n");
output(session.execute(":unset s").unwrap());
assert_eq!(
session.suggestion_text(),
"no runnable query yet; create missing bindings with :let\n"
);
assert!(session.completions().queries.is_empty());
}
#[test]
fn case_listing_reset_and_dirty_tracking_make_state_changes_explicit() {
let mut session = session();
let cases = output(session.execute(":test").unwrap());
assert!(cases.contains("* yes_case (current)"), "{cases}");
output(session.execute("outcome(s)").unwrap());
assert!(session.last_evaluation.is_some());
output(session.execute(":set s.flag = false").unwrap());
assert!(session.dirty);
assert!(session.last_evaluation.is_none());
assert!(output(session.execute(":explain outcome(s)").unwrap()).contains("Answer::No"));
assert!(session.last_evaluation.is_some());
let reset = output(session.execute(":reset").unwrap());
assert!(reset.contains("restored test `yes_case`"), "{reset}");
assert!(!session.dirty);
assert!(session.last_evaluation.is_none());
assert!(output(session.execute("outcome(s)").unwrap()).contains("Answer::Yes"));
output(session.execute(":set s.flag = false").unwrap());
let switched = output(session.execute(":test yes_case").unwrap());
assert!(switched.contains("discarded modified input"), "{switched}");
assert!(switched.contains("try: outcome(s)"), "{switched}");
output(session.execute(":set s.flag = true").unwrap());
assert!(!session.dirty, "setting the baseline value must stay clean");
}
#[test]
fn multi_binding_query_completion_prefers_the_case_query_and_covers_combinations() {
let mut session = session_from_source(
"conflicts.tes",
include_str!("../../../examples/schedule-conflicts/conflicts.tes"),
);
let startup = session.suggestion_text();
assert!(startup.contains(":test "), "{startup}");
assert!(!startup.contains(":let"), "{startup}");
let selected = output(session.execute(":test 부분겹침").unwrap());
assert!(selected.contains("try: 충돌사유(a, b)"), "{selected}");
let queries = &session.completion_snapshot.queries;
assert_eq!(queries.first().map(String::as_str), Some("충돌사유(a, b)"));
for query in [
"충돌사유(a, a)",
"충돌사유(a, b)",
"충돌사유(b, a)",
"충돌사유(b, b)",
] {
assert!(
queries.iter().any(|candidate| candidate == query),
"{queries:?}"
);
}
}
#[test]
fn query_completion_is_bounded_and_cached_until_binding_shape_changes() {
let source = r"mod completion_cap
record E:
flag: Bool
dec pair(left E, right E) -> Bool
";
let mut session = session_from_source("completion-cap.tes", source);
for index in 0..12 {
session
.input
.insert(format!("x{index:02}"), "E", BTreeMap::new());
}
session.refresh_completions();
assert_eq!(
session.completion_snapshot.queries.len(),
MAX_QUERY_COMPLETIONS
);
assert!(session.completion_snapshot.queries_truncated);
let revision = session.completion_revision();
output(session.execute(":show").unwrap());
output(session.execute(":set x00.flag true").unwrap());
assert_eq!(session.completion_revision(), revision);
output(session.execute(":unset x11").unwrap());
assert!(session.completion_revision() > revision);
}
#[test]
fn finite_field_domains_drive_repl_value_completion() {
let output = compile_source(SourceFile::new(
"domain.tes",
"mod m\nrecord E:\n status: String {\"draft\", \"approved\"}\n rate: Decimal {0.05, 0.10}\n",
));
assert!(output.diagnostics.is_empty(), "{:#?}", output.diagnostics);
let program = output.program.unwrap();
let fields = &program.entity("E").unwrap().fields;
assert_eq!(
value_completions(&program, &fields[0]),
["\"draft\"", "\"approved\""]
);
assert_eq!(value_completions(&program, &fields[1]), ["0.05", "0.10"]);
}
#[test]
fn reload_refreshes_clean_cases_preserves_dirty_input_and_detaches_deleted_cases() {
let unique = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let directory =
std::env::temp_dir().join(format!("tess-repl-reload-{}-{unique}", std::process::id()));
std::fs::create_dir_all(&directory).unwrap();
let path = directory.join("repl.tes");
std::fs::write(&path, SOURCE).unwrap();
let loaded = load(&path).unwrap_or_else(|_| panic!("initial REPL source should load"));
let (mut session, _) = Session::new(path.clone(), loaded).unwrap();
let updated = SOURCE.replacen("flag: true,", "flag: false,", 1).replacen(
"expect outcome(s) = Yes",
"expect outcome(s) = No",
1,
);
std::fs::write(&path, &updated).unwrap();
let reloaded = output(session.execute(":reload").unwrap());
assert!(reloaded.contains("refreshed current test"), "{reloaded}");
assert!(output(session.execute("outcome(s)").unwrap()).contains("Answer::No"));
output(session.execute(":set s.flag = true").unwrap());
let reloaded = output(session.execute(":reload").unwrap());
assert!(reloaded.contains("kept modified input"), "{reloaded}");
assert!(output(session.execute("outcome(s)").unwrap()).contains("Answer::Yes"));
let without_case = updated.split("test yes_case:").next().unwrap();
std::fs::write(&path, without_case).unwrap();
let reloaded = output(session.execute(":reload").unwrap());
assert!(
reloaded.contains("detached deleted test `yes_case`"),
"{reloaded}"
);
assert!(session.case_name.is_none());
std::fs::remove_dir_all(directory).unwrap();
}
}