use crate::ast::{
ActionDecl, BindingDecl, CaseDecl, Declaration, DeriveDecl, Effect, EntityDecl, EnumDecl,
Expectation, Expr, ExprKind, FieldDecl, FieldValue, FixtureDecl, FragmentDecl, ImportDecl,
InvariantAssertion, InvariantDecl, Parameter, Program, RuleDecl, StateDecl, TraceDecl,
TransitionDecl,
};
use crate::compiler::{CompileOutput, compile, normalize_name};
use crate::diagnostic::Diagnostic;
use crate::lexer::canonical_identifier;
use crate::parser::{ParseOutput, parse};
use crate::source::{SourceFile, Span, Spanned};
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Clone, Debug)]
pub struct ProjectCompileOutput {
pub source: SourceFile,
pub files: Vec<PathBuf>,
pub compile: CompileOutput,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PackageDependency {
pub alias: String,
pub entry: PathBuf,
pub root: PathBuf,
pub exports: BTreeSet<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PackageDependencyGraph {
pub dependency: PackageDependency,
pub dependencies: Vec<PackageDependencyGraph>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ProjectIoError {
message: String,
}
impl ProjectIoError {
fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
}
}
}
impl fmt::Display for ProjectIoError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.message)
}
}
impl std::error::Error for ProjectIoError {}
pub fn compile_project(
entry: &Path,
project_root: &Path,
) -> Result<ProjectCompileOutput, ProjectIoError> {
compile_project_with_dependencies_and_overrides(entry, project_root, &[], &BTreeMap::new())
}
pub fn compile_project_with_dependencies(
entry: &Path,
project_root: &Path,
dependencies: &[PackageDependency],
) -> Result<ProjectCompileOutput, ProjectIoError> {
compile_project_with_dependencies_and_overrides(
entry,
project_root,
dependencies,
&BTreeMap::new(),
)
}
pub fn compile_project_with_dependency_graph(
entry: &Path,
project_root: &Path,
dependencies: &[PackageDependencyGraph],
) -> Result<ProjectCompileOutput, ProjectIoError> {
compile_project_with_dependency_graph_and_overrides(
entry,
project_root,
dependencies,
&BTreeMap::new(),
)
}
pub fn compile_project_with_overrides(
entry: &Path,
project_root: &Path,
overrides: &BTreeMap<PathBuf, String>,
) -> Result<ProjectCompileOutput, ProjectIoError> {
compile_project_with_dependencies_and_overrides(entry, project_root, &[], overrides)
}
pub fn compile_project_with_dependencies_and_overrides(
entry: &Path,
project_root: &Path,
dependencies: &[PackageDependency],
overrides: &BTreeMap<PathBuf, String>,
) -> Result<ProjectCompileOutput, ProjectIoError> {
let graph = dependencies
.iter()
.cloned()
.map(|dependency| PackageDependencyGraph {
dependency,
dependencies: Vec::new(),
})
.collect::<Vec<_>>();
compile_project_with_dependency_graph_and_overrides(entry, project_root, &graph, overrides)
}
pub fn compile_project_with_dependency_graph_and_overrides(
entry: &Path,
project_root: &Path,
dependencies: &[PackageDependencyGraph],
overrides: &BTreeMap<PathBuf, String>,
) -> Result<ProjectCompileOutput, ProjectIoError> {
let root_loader = load_project(entry, project_root, overrides)?;
let mut loaded_dependencies = Vec::new();
let mut ordered = dependencies.to_vec();
ordered.sort_by(|left, right| {
normalize_package_name(&left.dependency.alias)
.cmp(&normalize_package_name(&right.dependency.alias))
});
let mut aliases = BTreeSet::new();
for dependency in ordered {
validate_package_alias(&dependency.dependency.alias)?;
let alias = normalize_package_name(&dependency.dependency.alias);
if !aliases.insert(alias.clone()) {
return Err(ProjectIoError::new(format!(
"duplicate package alias `{alias}`"
)));
}
let mut loader = load_dependency_graph(&dependency, overrides)?;
qualify_dependency(&mut loader, &alias, &dependency.dependency.exports)?;
loaded_dependencies.push(loader);
}
Ok(finish_loaders(root_loader, loaded_dependencies))
}
fn load_dependency_graph<'overrides>(
graph: &PackageDependencyGraph,
overrides: &'overrides BTreeMap<PathBuf, String>,
) -> Result<ProjectLoader<'overrides>, ProjectIoError> {
let mut loader = load_project(&graph.dependency.entry, &graph.dependency.root, overrides)?;
let mut children = graph.dependencies.clone();
children.sort_by(|left, right| {
normalize_package_name(&left.dependency.alias)
.cmp(&normalize_package_name(&right.dependency.alias))
});
let mut aliases = BTreeSet::new();
for child in children {
validate_package_alias(&child.dependency.alias)?;
let alias = normalize_package_name(&child.dependency.alias);
if !aliases.insert(alias.clone()) {
return Err(ProjectIoError::new(format!(
"duplicate package alias `{alias}`"
)));
}
let mut child_loader = load_dependency_graph(&child, overrides)?;
qualify_dependency(&mut child_loader, &alias, &child.dependency.exports)?;
loader.modules.append(&mut child_loader.modules);
}
Ok(loader)
}
fn load_project<'overrides>(
entry: &Path,
project_root: &Path,
overrides: &'overrides BTreeMap<PathBuf, String>,
) -> Result<ProjectLoader<'overrides>, ProjectIoError> {
let root = fs::canonicalize(project_root).map_err(|error| {
ProjectIoError::new(format!(
"could not resolve project root `{}`: {error}",
project_root.display()
))
})?;
let entry = fs::canonicalize(entry).map_err(|error| {
ProjectIoError::new(format!(
"could not read entry file `{}`: {error}",
entry.display()
))
})?;
if !entry.starts_with(&root) {
return Err(ProjectIoError::new(format!(
"entry file `{}` is outside project root `{}`",
entry.display(),
root.display()
)));
}
if !entry.is_file() {
return Err(ProjectIoError::new(format!(
"entry `{}` is not a file",
entry.display()
)));
}
let mut loader = ProjectLoader::new(root, overrides);
loader.visit(entry, None)?;
Ok(loader)
}
struct LoadedModule {
path: PathBuf,
source: SourceFile,
program: Option<Program>,
diagnostics: Vec<Diagnostic>,
}
struct ProjectLoader<'overrides> {
root: PathBuf,
expected_module: Option<String>,
modules: Vec<LoadedModule>,
visited: BTreeSet<PathBuf>,
visiting: Vec<PathBuf>,
overrides: &'overrides BTreeMap<PathBuf, String>,
}
impl<'overrides> ProjectLoader<'overrides> {
fn new(root: PathBuf, overrides: &'overrides BTreeMap<PathBuf, String>) -> Self {
Self {
root,
expected_module: None,
modules: Vec::new(),
visited: BTreeSet::new(),
visiting: Vec::new(),
overrides,
}
}
fn visit(
&mut self,
path: PathBuf,
import_site: Option<(&Path, Span)>,
) -> Result<(), ProjectIoError> {
if self.visited.contains(&path) {
return Ok(());
}
let text = match self
.overrides
.get(&path)
.cloned()
.map_or_else(|| fs::read_to_string(&path), Ok)
{
Ok(text) => text,
Err(error) => {
if let Some((importer, span)) = import_site {
self.add_diagnostic(
importer,
Diagnostic::error(
"M0001",
format!(
"could not read file referenced by `use` `{}`: {error}",
path.display()
),
span,
),
);
return Ok(());
}
return Err(ProjectIoError::new(format!(
"could not read entry file `{}`: {error}",
path.display()
)));
}
};
let source = SourceFile::new(path.display().to_string(), text);
let parsed = parse(source.clone());
let module_name = parsed
.program
.as_ref()
.map(|program| program.module.value.clone());
if self.expected_module.is_none() {
self.expected_module.clone_from(&module_name);
}
let index = self.modules.len();
self.modules.push(LoadedModule {
path: path.clone(),
source,
program: parsed.program,
diagnostics: parsed.diagnostics,
});
self.visited.insert(path.clone());
self.visiting.push(path.clone());
let module_mismatch = self
.expected_module
.as_ref()
.zip(module_name)
.filter(|(expected, actual)| actual != *expected);
if let Some((expected, actual)) = module_mismatch {
let module_span = self.modules[index]
.program
.as_ref()
.expect("a parsed module name implies a program")
.module
.span;
self.modules[index].diagnostics.push(
Diagnostic::error(
"M0005",
format!(
"used file declares module `{actual}`, but this project is module `{expected}`"
),
module_span,
)
.with_help(format!("change this header to `mod {expected}`")),
);
}
let imports = self.modules[index]
.program
.as_ref()
.map(|program| program.imports.clone())
.unwrap_or_default();
let mut direct_imports: BTreeMap<PathBuf, Span> = BTreeMap::new();
for import in imports {
let Some(target) = self.resolve_import(&path, &import) else {
continue;
};
if let Some(first) = direct_imports.insert(target.clone(), import.path.span) {
self.modules[index].diagnostics.push(
Diagnostic::error(
"M0004",
format!("duplicate use of `{}`", import.path.value),
import.path.span,
)
.with_label(first, "first use is here")
.with_help("remove the repeated `use`; each file is loaded once"),
);
continue;
}
if let Some(cycle_start) = self.visiting.iter().position(|item| item == &target) {
let mut cycle: Vec<_> = self.visiting[cycle_start..]
.iter()
.map(|item| relative_display(&self.root, item))
.collect();
cycle.push(relative_display(&self.root, &target));
self.modules[index].diagnostics.push(
Diagnostic::error(
"M0003",
format!("use cycle: {}", cycle.join(" -> ")),
import.path.span,
)
.with_help("remove one `use` so the dependency graph is acyclic"),
);
continue;
}
self.visit(target, Some((&path, import.path.span)))?;
}
let popped = self.visiting.pop();
debug_assert_eq!(popped.as_ref(), Some(&path));
Ok(())
}
fn resolve_import(&mut self, importer: &Path, import: &ImportDecl) -> Option<PathBuf> {
let requested = Path::new(&import.path.value);
if requested.is_absolute() {
self.add_diagnostic(
importer,
Diagnostic::error("M0002", "use paths must be relative", import.path.span)
.with_help("use a path relative to the file containing this `use`"),
);
return None;
}
if requested
.extension()
.and_then(|extension| extension.to_str())
!= Some("tes")
{
self.add_diagnostic(
importer,
Diagnostic::error("M0002", "a `use` path must end in `.tes`", import.path.span),
);
return None;
}
let joined = importer
.parent()
.unwrap_or_else(|| Path::new("."))
.join(requested);
let target = match fs::canonicalize(&joined) {
Ok(path) => path,
Err(error) => {
self.add_diagnostic(
importer,
Diagnostic::error(
"M0001",
format!(
"could not resolve `use` path `{}`: {error}",
import.path.value
),
import.path.span,
),
);
return None;
}
};
if !target.starts_with(&self.root) {
self.add_diagnostic(
importer,
Diagnostic::error(
"M0002",
format!(
"use path `{}` resolves outside project root `{}`",
import.path.value,
self.root.display()
),
import.path.span,
)
.with_help("move the file into the project or choose the project directory as the CLI target"),
);
return None;
}
if !target.is_file() {
self.add_diagnostic(
importer,
Diagnostic::error(
"M0001",
format!("use path `{}` is not a file", import.path.value),
import.path.span,
),
);
return None;
}
Some(target)
}
fn add_diagnostic(&mut self, importer: &Path, diagnostic: Diagnostic) {
if let Some(module) = self
.modules
.iter_mut()
.find(|module| module.path == importer)
{
module.diagnostics.push(diagnostic);
}
}
fn finish(mut self) -> ProjectCompileOutput {
let sources: Vec<_> = self
.modules
.iter()
.map(|module| module.source.clone())
.collect();
let display_name = self.modules.first().map_or_else(
|| "<project>".to_owned(),
|module| module.path.display().to_string(),
);
let (source, offsets) = SourceFile::bundle(display_name, &sources);
let files = self
.modules
.iter()
.map(|module| module.path.clone())
.collect();
let mut diagnostics = Vec::new();
let mut merged_program: Option<Program> = None;
for (index, module) in self.modules.iter_mut().enumerate() {
let offset = offsets[index];
diagnostics.extend(
module
.diagnostics
.drain(..)
.map(|diagnostic| shift_diagnostic(diagnostic, offset)),
);
let Some(mut program) = module.program.take() else {
continue;
};
shift_program(&mut program, offset);
if let Some(merged) = &mut merged_program {
merged.imports.append(&mut program.imports);
merged.declarations.append(&mut program.declarations);
} else {
program.source = source.clone();
merged_program = Some(program);
}
}
if let Some(program) = &mut merged_program {
program.source = source.clone();
program.imports_resolved = true;
}
let compile = compile(ParseOutput {
program: merged_program,
diagnostics,
});
ProjectCompileOutput {
source,
files,
compile,
}
}
}
fn finish_loaders<'overrides>(
mut root: ProjectLoader<'overrides>,
dependencies: Vec<ProjectLoader<'overrides>>,
) -> ProjectCompileOutput {
for mut dependency in dependencies {
root.modules.append(&mut dependency.modules);
}
root.finish()
}
#[derive(Default)]
struct PackageNames {
all: BTreeMap<String, String>,
types: BTreeMap<String, String>,
enum_variants: BTreeMap<String, (String, BTreeSet<String>)>,
entities: BTreeMap<String, String>,
derives: BTreeMap<String, String>,
decisions: BTreeMap<String, String>,
actions: BTreeMap<String, String>,
rules: BTreeMap<String, String>,
fragments: BTreeMap<String, String>,
fixtures: BTreeMap<String, String>,
}
fn validate_package_alias(alias: &str) -> Result<(), ProjectIoError> {
if canonical_identifier(alias).is_none() {
return Err(ProjectIoError::new(format!(
"package alias `{alias}` must be one Unicode identifier that is not a hard keyword"
)));
}
Ok(())
}
fn normalize_package_name(name: &str) -> String {
normalize_name(name)
}
fn qualify_dependency(
loader: &mut ProjectLoader<'_>,
alias: &str,
exports: &BTreeSet<String>,
) -> Result<(), ProjectIoError> {
let exports = exports
.iter()
.map(|name| normalize_name(name))
.collect::<BTreeSet<_>>();
let declarations = loader
.modules
.iter()
.filter_map(|module| module.program.as_ref())
.flat_map(|program| program.declarations.iter())
.collect::<Vec<_>>();
let available = declarations
.iter()
.map(|declaration| normalize_name(&declaration.name().value))
.collect::<BTreeSet<_>>();
if let Some(missing) = exports.iter().find(|name| !available.contains(*name)) {
return Err(ProjectIoError::new(format!(
"package `{alias}` does not declare exported symbol `{missing}`"
)));
}
let mut names = PackageNames::default();
for declaration in declarations {
let original = declaration.name().value.clone();
let key = normalize_name(&original);
let prefix = if exports.contains(&key) {
alias.to_owned()
} else {
format!("@pkg_{alias}")
};
let qualified = format!("{prefix}.{original}");
names.all.insert(key.clone(), qualified.clone());
match declaration {
Declaration::Enum(value) => {
names.enum_variants.insert(
key.clone(),
(
qualified.clone(),
value
.variants
.iter()
.map(|variant| normalize_name(&variant.value))
.collect(),
),
);
names.types.insert(key, qualified);
}
Declaration::State(_) => {
names.types.insert(key, qualified);
}
Declaration::Entity(_) => {
names.types.insert(key.clone(), qualified.clone());
names.entities.insert(key, qualified);
}
Declaration::Derive(_) => {
names.derives.insert(key, qualified);
}
Declaration::Decision(_) => {
names.decisions.insert(key, qualified);
}
Declaration::Action(_) => {
names.actions.insert(key, qualified);
}
Declaration::Rule(_) => {
names.rules.insert(key, qualified);
}
Declaration::Fixture(_) => {
names.fixtures.insert(key, qualified);
}
Declaration::Fragment(value) => {
names.fragments.insert(key, qualified.clone());
for derive in &value.derives {
let derive_id = format!("{original}.{}", derive.name.value);
let qualified_derive_id = format!("{qualified}.{}", derive.name.value);
names
.derives
.insert(normalize_name(&derive_id), qualified_derive_id);
}
for rule in &value.rules {
let rule_id = format!("{original}.{}", rule.name.value);
let qualified_rule_id = format!("{qualified}.{}", rule.name.value);
names
.rules
.insert(normalize_name(&rule_id), qualified_rule_id);
}
}
Declaration::Transition(_)
| Declaration::Case(_)
| Declaration::Invariant(_)
| Declaration::Trace(_) => {}
}
}
for module in &mut loader.modules {
let Some(program) = &mut module.program else {
continue;
};
program.declarations.retain(|declaration| {
!matches!(
declaration,
Declaration::Case(_) | Declaration::Invariant(_) | Declaration::Trace(_)
) || exports.contains(&normalize_name(&declaration.name().value))
});
for declaration in &mut program.declarations {
rewrite_package_declaration(declaration, &names);
}
}
Ok(())
}
fn mapped_name(map: &BTreeMap<String, String>, value: &mut Spanned<String>) {
if let Some(mapped) = map.get(&normalize_name(&value.value)) {
value.value.clone_from(mapped);
}
}
fn rewrite_type_ref(value: &mut crate::ast::TypeRef, names: &PackageNames) {
let crate::ast::TypeRef::Named(name) = value else {
return;
};
if let Some(mapped) = names.types.get(&normalize_name(name)) {
name.clone_from(mapped);
}
}
fn rewrite_parameter(parameter: &mut Parameter, names: &PackageNames) {
mapped_name(&names.types, &mut parameter.ty);
}
fn rewrite_field(field: &mut FieldDecl, names: &PackageNames) {
rewrite_type_ref(&mut field.ty, names);
if let Some(domain) = &mut field.domain {
for value in &mut domain.values {
rewrite_expr(value, names);
}
}
}
fn rewrite_expr(expression: &mut Expr, names: &PackageNames) {
rewrite_expr_with_scope(expression, names, &BTreeSet::new());
}
fn rewrite_expr_with_scope(expression: &mut Expr, names: &PackageNames, scope: &BTreeSet<String>) {
if let Some(path) = package_dotted_expression_path(expression) {
if let Some(root) = path.split('.').next() {
if !scope.contains(&normalize_name(root)) {
if let Some((enum_name, variant)) = path.rsplit_once('.') {
if let Some((qualified, variants)) =
names.enum_variants.get(&normalize_name(enum_name))
{
if variants.contains(&normalize_name(variant)) {
expression.kind = ExprKind::Name(format!("{qualified}.{variant}"));
return;
}
}
}
}
}
}
match &mut expression.kind {
ExprKind::Literal(_) | ExprKind::Name(_) => {}
ExprKind::Field { receiver, .. } => rewrite_expr_with_scope(receiver, names, scope),
ExprKind::Call { callee, arguments } => {
mapped_name(&names.derives, callee);
for argument in arguments {
rewrite_expr_with_scope(argument, names, scope);
}
}
ExprKind::Unary { operand, .. } => rewrite_expr_with_scope(operand, names, scope),
ExprKind::Binary { left, right, .. } => {
rewrite_expr_with_scope(left, names, scope);
rewrite_expr_with_scope(right, names, scope);
}
}
}
fn package_dotted_expression_path(expression: &Expr) -> Option<String> {
match &expression.kind {
ExprKind::Name(name) => Some(name.clone()),
ExprKind::Field { receiver, field } => Some(format!(
"{}.{}",
package_dotted_expression_path(receiver)?,
field.value
)),
_ => None,
}
}
fn rewrite_derive(value: &mut DeriveDecl, names: &PackageNames, nested: bool) {
if !nested {
mapped_name(&names.all, &mut value.name);
}
for parameter in &mut value.parameters {
rewrite_parameter(parameter, names);
}
rewrite_type_ref(&mut value.return_type.value, names);
let scope = parameter_name_set(&value.parameters);
rewrite_expr_with_scope(&mut value.expression, names, &scope);
for basis in &mut value.basis {
mapped_name(&names.fragments, &mut basis.id);
}
}
fn seed_package_implicit_parameters(value: &mut RuleDecl, names: &PackageNames) {
if !value.implicit_parameters {
return;
}
seed_package_parameters_from_expression(&value.condition, names, &mut value.parameters, false);
if let Effect::Decide {
arguments,
value: result,
..
} = &value.effect
{
for argument in arguments {
seed_package_parameters_from_expression(argument, names, &mut value.parameters, true);
}
seed_package_parameters_from_expression(result, names, &mut value.parameters, false);
}
}
fn seed_package_parameters_from_expression(
expression: &Expr,
names: &PackageNames,
parameters: &mut Vec<Parameter>,
allow_bare_name: bool,
) {
match &expression.kind {
ExprKind::Literal(_) => {}
ExprKind::Name(name) => {
if allow_bare_name {
seed_package_parameter(name, expression.span, names, parameters);
}
}
ExprKind::Field { receiver, .. } => {
seed_package_field_receiver(receiver, names, parameters);
}
ExprKind::Call { arguments, .. } => {
for argument in arguments {
seed_package_parameters_from_expression(argument, names, parameters, true);
}
}
ExprKind::Unary { operand, .. } => {
seed_package_parameters_from_expression(operand, names, parameters, false);
}
ExprKind::Binary { left, right, .. } => {
seed_package_parameters_from_expression(left, names, parameters, false);
seed_package_parameters_from_expression(right, names, parameters, false);
}
}
}
fn seed_package_field_receiver(
expression: &Expr,
names: &PackageNames,
parameters: &mut Vec<Parameter>,
) {
match &expression.kind {
ExprKind::Name(name) => {
seed_package_parameter(name, expression.span, names, parameters);
}
ExprKind::Field { receiver, .. } => {
seed_package_field_receiver(receiver, names, parameters);
}
_ => seed_package_parameters_from_expression(expression, names, parameters, false),
}
}
fn seed_package_parameter(
name: &str,
span: Span,
names: &PackageNames,
parameters: &mut Vec<Parameter>,
) {
let normalized = normalize_name(name);
let Some(entity) = names.entities.get(&normalized) else {
return;
};
if parameters
.iter()
.any(|parameter| normalize_name(¶meter.name.value) == normalized)
{
return;
}
parameters.push(Parameter {
ty: Spanned::new(entity.clone(), span),
name: Spanned::new(name.to_owned(), span),
span,
});
}
fn rewrite_rule(value: &mut RuleDecl, names: &PackageNames, nested: bool) {
if !nested {
mapped_name(&names.rules, &mut value.name);
}
seed_package_implicit_parameters(value, names);
for parameter in &mut value.parameters {
rewrite_parameter(parameter, names);
}
let scope = parameter_name_set(&value.parameters);
rewrite_expr_with_scope(&mut value.condition, names, &scope);
match &mut value.effect {
Effect::Decide {
decision,
arguments,
value,
..
} => {
mapped_name(&names.decisions, decision);
for argument in arguments {
rewrite_expr_with_scope(argument, names, &scope);
}
rewrite_expr_with_scope(value, names, &scope);
}
Effect::Override { rule, .. } => {
if !nested || rule.value.contains('.') {
mapped_name(&names.rules, rule);
}
}
Effect::Invalid { .. } => {}
}
for basis in &mut value.basis {
mapped_name(&names.fragments, &mut basis.id);
}
}
fn parameter_name_set(parameters: &[Parameter]) -> BTreeSet<String> {
parameters
.iter()
.map(|parameter| normalize_name(¶meter.name.value))
.collect()
}
#[allow(clippy::too_many_lines)]
fn rewrite_package_declaration(declaration: &mut Declaration, names: &PackageNames) {
match declaration {
Declaration::Enum(value) => mapped_name(&names.all, &mut value.name),
Declaration::Entity(value) => {
mapped_name(&names.all, &mut value.name);
for field in &mut value.fields {
rewrite_field(field, names);
}
}
Declaration::State(value) => {
mapped_name(&names.all, &mut value.name);
for field in &mut value.fields {
rewrite_field(field, names);
}
}
Declaration::Derive(value) => {
rewrite_derive(value, names, false);
}
Declaration::Decision(value) => {
mapped_name(&names.all, &mut value.name);
for parameter in &mut value.parameters {
rewrite_parameter(parameter, names);
}
rewrite_type_ref(&mut value.return_type.value, names);
}
Declaration::Action(value) => {
mapped_name(&names.all, &mut value.name);
for parameter in &mut value.parameters {
rewrite_parameter(parameter, names);
}
}
Declaration::Transition(value) => {
mapped_name(&names.all, &mut value.name);
for parameter in &mut value.parameters {
rewrite_parameter(parameter, names);
}
let scope = parameter_name_set(&value.parameters);
mapped_name(&names.actions, &mut value.action);
for argument in &mut value.action_arguments {
rewrite_expr_with_scope(argument, names, &scope);
}
rewrite_expr_with_scope(&mut value.condition, names, &scope);
for update in &mut value.updates {
rewrite_expr_with_scope(&mut update.value, names, &scope);
}
}
Declaration::Fragment(value) => {
mapped_name(&names.fragments, &mut value.id);
for reference in &mut value.refs {
mapped_name(&names.fragments, &mut reference.id);
}
for derive in &mut value.derives {
rewrite_derive(derive, names, true);
}
for rule in &mut value.rules {
rewrite_rule(rule, names, true);
}
}
Declaration::Rule(value) => {
rewrite_rule(value, names, false);
}
Declaration::Fixture(value) => {
mapped_name(&names.fixtures, &mut value.name);
mapped_name(&names.types, &mut value.entity);
for field in &mut value.fields {
rewrite_expr(&mut field.value, names);
}
}
Declaration::Case(value) => {
mapped_name(&names.all, &mut value.name);
let scope = value
.bindings
.iter()
.map(|binding| normalize_name(&binding.name.value))
.collect::<BTreeSet<_>>();
for binding in &mut value.bindings {
mapped_name(&names.types, &mut binding.entity);
if let Some(fixture) = &mut binding.fixture {
mapped_name(&names.fixtures, fixture);
}
for field in &mut binding.fields {
rewrite_expr_with_scope(&mut field.value, names, &scope);
}
}
for expectation in &mut value.expectations {
mapped_name(&names.decisions, &mut expectation.decision);
for argument in &mut expectation.arguments {
rewrite_expr_with_scope(argument, names, &scope);
}
rewrite_expr_with_scope(&mut expectation.value, names, &scope);
}
}
Declaration::Invariant(value) => {
mapped_name(&names.all, &mut value.name);
for variable in &mut value.variables {
rewrite_parameter(variable, names);
}
let scope = parameter_name_set(&value.variables);
match &mut value.assertion {
InvariantAssertion::Cardinality {
decision,
arguments,
..
} => {
mapped_name(&names.decisions, decision);
for argument in arguments {
rewrite_expr_with_scope(argument, names, &scope);
}
}
InvariantAssertion::Implication {
condition,
expectation,
..
} => {
rewrite_expr_with_scope(condition, names, &scope);
mapped_name(&names.decisions, &mut expectation.decision);
for argument in &mut expectation.arguments {
rewrite_expr_with_scope(argument, names, &scope);
}
rewrite_expr_with_scope(&mut expectation.value, names, &scope);
}
}
}
Declaration::Trace(value) => {
mapped_name(&names.all, &mut value.name);
rewrite_parameter(&mut value.variable, names);
let scope = parameter_name_set(std::slice::from_ref(&value.variable));
rewrite_expr_with_scope(&mut value.initial, names, &scope);
for condition in &mut value.always {
rewrite_expr_with_scope(condition, names, &scope);
}
if let Some(condition) = &mut value.terminal {
rewrite_expr_with_scope(condition, names, &scope);
}
}
}
}
fn relative_display(root: &Path, path: &Path) -> String {
path.strip_prefix(root)
.unwrap_or(path)
.display()
.to_string()
}
fn shifted(span: &mut Span, offset: usize) {
span.start += offset;
span.end += offset;
}
fn shift_spanned<T>(value: &mut Spanned<T>, offset: usize) {
shifted(&mut value.span, offset);
}
fn shift_diagnostic(mut diagnostic: Diagnostic, offset: usize) -> Diagnostic {
shifted(&mut diagnostic.primary, offset);
for label in &mut diagnostic.labels {
shifted(&mut label.span, offset);
}
diagnostic
}
fn shift_program(program: &mut Program, offset: usize) {
shift_spanned(&mut program.module, offset);
for import in &mut program.imports {
shift_spanned(&mut import.path, offset);
shifted(&mut import.span, offset);
}
for declaration in &mut program.declarations {
shift_declaration(declaration, offset);
}
}
fn shift_declaration(declaration: &mut Declaration, offset: usize) {
match declaration {
Declaration::Enum(value) => shift_enum(value, offset),
Declaration::Entity(value) => shift_entity(value, offset),
Declaration::State(value) => shift_state(value, offset),
Declaration::Derive(value) => shift_derive(value, offset),
Declaration::Decision(value) => {
shift_spanned(&mut value.name, offset);
for parameter in &mut value.parameters {
shift_parameter(parameter, offset);
}
shift_spanned(&mut value.return_type, offset);
shifted(&mut value.span, offset);
}
Declaration::Action(value) => shift_action(value, offset),
Declaration::Transition(value) => shift_transition(value, offset),
Declaration::Fragment(value) => shift_fragment(value, offset),
Declaration::Rule(value) => shift_rule(value, offset),
Declaration::Fixture(value) => shift_fixture(value, offset),
Declaration::Case(value) => shift_case(value, offset),
Declaration::Invariant(value) => shift_invariant(value, offset),
Declaration::Trace(value) => shift_trace(value, offset),
}
}
fn shift_enum(value: &mut EnumDecl, offset: usize) {
shift_spanned(&mut value.name, offset);
for variant in &mut value.variants {
shift_spanned(variant, offset);
}
shifted(&mut value.span, offset);
}
fn shift_entity(value: &mut EntityDecl, offset: usize) {
shift_spanned(&mut value.name, offset);
for field in &mut value.fields {
shift_field(field, offset);
}
shifted(&mut value.span, offset);
}
fn shift_state(value: &mut StateDecl, offset: usize) {
shift_spanned(&mut value.name, offset);
for field in &mut value.fields {
shift_field(field, offset);
}
shifted(&mut value.span, offset);
}
fn shift_field(value: &mut FieldDecl, offset: usize) {
shift_spanned(&mut value.name, offset);
if let Some(range) = &mut value.range {
shifted(&mut range.span, offset);
}
if let Some(domain) = &mut value.domain {
for expression in &mut domain.values {
shift_expr(expression, offset);
}
shifted(&mut domain.span, offset);
}
shifted(&mut value.span, offset);
}
fn shift_parameter(value: &mut Parameter, offset: usize) {
shift_spanned(&mut value.ty, offset);
shift_spanned(&mut value.name, offset);
shifted(&mut value.span, offset);
}
fn shift_derive(value: &mut DeriveDecl, offset: usize) {
shift_spanned(&mut value.name, offset);
for parameter in &mut value.parameters {
shift_parameter(parameter, offset);
}
shift_spanned(&mut value.return_type, offset);
shift_expr(&mut value.expression, offset);
for basis in &mut value.basis {
shift_spanned(&mut basis.id, offset);
}
shifted(&mut value.span, offset);
}
fn shift_action(value: &mut ActionDecl, offset: usize) {
shift_spanned(&mut value.name, offset);
for parameter in &mut value.parameters {
shift_parameter(parameter, offset);
}
shifted(&mut value.span, offset);
}
fn shift_transition(value: &mut TransitionDecl, offset: usize) {
shift_spanned(&mut value.name, offset);
for parameter in &mut value.parameters {
shift_parameter(parameter, offset);
}
shift_spanned(&mut value.action, offset);
for argument in &mut value.action_arguments {
shift_expr(argument, offset);
}
shift_expr(&mut value.condition, offset);
for update in &mut value.updates {
shift_spanned(&mut update.receiver, offset);
shift_spanned(&mut update.field, offset);
shift_expr(&mut update.value, offset);
shifted(&mut update.span, offset);
}
shifted(&mut value.span, offset);
}
fn shift_fragment(value: &mut FragmentDecl, offset: usize) {
shift_spanned(&mut value.id, offset);
shift_spanned(&mut value.locator, offset);
shift_spanned(&mut value.text, offset);
for reference in &mut value.refs {
shift_spanned(&mut reference.id, offset);
}
for derive in &mut value.derives {
shift_derive(derive, offset);
}
for rule in &mut value.rules {
shift_rule(rule, offset);
}
shifted(&mut value.span, offset);
}
fn shift_rule(value: &mut RuleDecl, offset: usize) {
shift_spanned(&mut value.name, offset);
for parameter in &mut value.parameters {
shift_parameter(parameter, offset);
}
shift_expr(&mut value.condition, offset);
match &mut value.effect {
Effect::Decide {
decision,
arguments,
value,
span,
..
} => {
shift_spanned(decision, offset);
for argument in arguments {
shift_expr(argument, offset);
}
shift_expr(value, offset);
shifted(span, offset);
}
Effect::Override { rule, span } => {
shift_spanned(rule, offset);
shifted(span, offset);
}
Effect::Invalid { span } => shifted(span, offset),
}
for basis in &mut value.basis {
shift_spanned(&mut basis.id, offset);
}
shifted(&mut value.span, offset);
}
fn shift_case(value: &mut CaseDecl, offset: usize) {
shift_spanned(&mut value.name, offset);
for binding in &mut value.bindings {
shift_binding(binding, offset);
}
for expectation in &mut value.expectations {
shift_expectation(expectation, offset);
}
shifted(&mut value.span, offset);
}
fn shift_fixture(value: &mut FixtureDecl, offset: usize) {
shift_spanned(&mut value.name, offset);
shift_spanned(&mut value.entity, offset);
for field in &mut value.fields {
shift_field_value(field, offset);
}
shifted(&mut value.span, offset);
}
fn shift_binding(value: &mut BindingDecl, offset: usize) {
shift_spanned(&mut value.name, offset);
shift_spanned(&mut value.entity, offset);
if let Some(fixture) = &mut value.fixture {
shift_spanned(fixture, offset);
}
for field in &mut value.fields {
shift_field_value(field, offset);
}
shifted(&mut value.span, offset);
}
fn shift_field_value(value: &mut FieldValue, offset: usize) {
shift_spanned(&mut value.name, offset);
shift_expr(&mut value.value, offset);
shifted(&mut value.span, offset);
}
fn shift_expectation(value: &mut Expectation, offset: usize) {
shift_spanned(&mut value.decision, offset);
for argument in &mut value.arguments {
shift_expr(argument, offset);
}
shift_expr(&mut value.value, offset);
shifted(&mut value.span, offset);
}
fn shift_invariant(value: &mut InvariantDecl, offset: usize) {
shift_spanned(&mut value.name, offset);
for variable in &mut value.variables {
shift_parameter(variable, offset);
}
match &mut value.assertion {
InvariantAssertion::Cardinality {
decision,
arguments,
span,
..
} => {
shift_spanned(decision, offset);
for argument in arguments {
shift_expr(argument, offset);
}
shifted(span, offset);
}
InvariantAssertion::Implication {
condition,
expectation,
span,
} => {
shift_expr(condition, offset);
shift_expectation(expectation, offset);
shifted(span, offset);
}
}
shifted(&mut value.span, offset);
}
fn shift_trace(value: &mut TraceDecl, offset: usize) {
shift_spanned(&mut value.name, offset);
shift_parameter(&mut value.variable, offset);
shift_expr(&mut value.initial, offset);
for condition in &mut value.always {
shift_expr(condition, offset);
}
if let Some(condition) = &mut value.terminal {
shift_expr(condition, offset);
}
shift_spanned(&mut value.within, offset);
shifted(&mut value.span, offset);
}
fn shift_expr(value: &mut Expr, offset: usize) {
match &mut value.kind {
ExprKind::Literal(_) | ExprKind::Name(_) => {}
ExprKind::Field { receiver, field } => {
shift_expr(receiver, offset);
shift_spanned(field, offset);
}
ExprKind::Call { callee, arguments } => {
shift_spanned(callee, offset);
for argument in arguments {
shift_expr(argument, offset);
}
}
ExprKind::Unary { operand, .. } => shift_expr(operand, offset),
ExprKind::Binary { left, right, .. } => {
shift_expr(left, offset);
shift_expr(right, offset);
}
}
shifted(&mut value.span, offset);
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
static NEXT_DIRECTORY: AtomicU64 = AtomicU64::new(0);
struct TestDirectory(PathBuf);
impl TestDirectory {
fn new() -> Self {
let id = NEXT_DIRECTORY.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir()
.join(format!("tess-project-tests-{}-{id}", std::process::id()));
fs::create_dir_all(&path).unwrap();
Self(path)
}
fn write(&self, relative: &str, text: &str) -> PathBuf {
let path = self.0.join(relative);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, text).unwrap();
path
}
}
impl Drop for TestDirectory {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
#[test]
fn package_aliases_share_the_language_identifier_contract() {
assert!(validate_package_alias("shared").is_ok());
assert!(validate_package_alias("state").is_ok());
assert!(validate_package_alias("e\u{301}").is_ok());
assert!(validate_package_alias("record").is_err());
assert!(validate_package_alias("1st").is_err());
}
#[test]
fn compiles_declarations_across_partial_module_files() {
let directory = TestDirectory::new();
let entry = directory.write(
"main.tes",
"mod shop\nuse \"model.tes\"\nuse \"rules.tes\"\n",
);
directory.write(
"model.tes",
"mod shop\nenum Answer:\n Yes\n No\n\nrecord Subject:\n flag: Bool\n\ndec answer(s Subject): Answer\n\npolicy.main @policy:\n Shop policy, section 1.\n",
);
directory.write(
"rules.tes",
"mod shop\nrule yes(s Subject):\n basis policy.main\n s.flag: answer(s) = Yes\n",
);
let output = compile_project(&entry, &directory.0).unwrap();
assert_eq!(output.files.len(), 3);
assert!(
output.compile.diagnostics.is_empty(),
"{:?}",
output.compile.diagnostics
);
let program = output.compile.program.unwrap();
assert!(program.entity("Subject").is_some());
assert!(program.rule("yes").is_some());
let source = program.fragment("policy.main").unwrap();
assert!(
program
.source()
.name_at(source.locator.span.start)
.ends_with("model.tes")
);
let reference = &program.rule("yes").unwrap().basis[0].id;
assert!(
program
.source()
.name_at(reference.span.start)
.ends_with("rules.tes")
);
}
#[test]
fn resolves_fixture_references_across_partial_module_files() {
let directory = TestDirectory::new();
let entry = directory.write(
"main.tes",
"mod shop\nuse \"model.tes\"\nuse \"tests.tes\"\n",
);
directory.write(
"model.tes",
"mod shop\nenum Answer:\n Yes\n No\n\nrecord Subject:\n flag: Bool\n label: String\n\ndec answer(Subject): Answer\n\nrule yes(Subject):\n Subject.flag: answer(Subject) = Yes\n",
);
directory.write(
"tests.tes",
"mod shop\nfixture enabled Subject:\n flag: true\n\ntest fixture_override:\n Subject from enabled { label: \"sample\" }\n expect answer(Subject) = Yes\n",
);
let output = compile_project(&entry, &directory.0).unwrap();
assert!(
output.compile.diagnostics.is_empty(),
"{:?}",
output.compile.diagnostics
);
let program = output.compile.program.unwrap();
assert!(crate::engine::run_tests(&program, None).success());
let case = program.case("fixture_override").unwrap();
let fixture = case.bindings[0].fixture.as_ref().unwrap();
assert!(
program
.source()
.name_at(fixture.span.start)
.ends_with("tests.tes")
);
}
#[test]
fn reports_cycle_duplicate_and_module_mismatch_at_original_files() {
let directory = TestDirectory::new();
let entry = directory.write("main.tes", "mod shop\nuse \"a.tes\"\nuse \"a.tes\"\n");
directory.write(
"a.tes",
"mod other\nuse \"main.tes\"\nenum Answer:\n Yes\n",
);
let output = compile_project(&entry, &directory.0).unwrap();
let codes: BTreeSet<_> = output
.compile
.diagnostics
.iter()
.map(|diagnostic| diagnostic.code.as_str())
.collect();
assert!(codes.contains("M0003"));
assert!(codes.contains("M0004"));
assert!(codes.contains("M0005"));
let mismatch = output
.compile
.diagnostics
.iter()
.find(|diagnostic| diagnostic.code == "M0005")
.unwrap();
assert!(
output
.source
.name_at(mismatch.primary.start)
.ends_with("a.tes")
);
assert_eq!(output.source.line_col(mismatch.primary.start), (1, 5));
}
#[test]
fn allows_diamond_imports_but_rejects_escape() {
let directory = TestDirectory::new();
let entry = directory.write(
"main.tes",
"mod shop\nuse \"left.tes\"\nuse \"right.tes\"\n",
);
directory.write("left.tes", "mod shop\nuse \"shared.tes\"\n");
directory.write("right.tes", "mod shop\nuse \"shared.tes\"\n");
directory.write("shared.tes", "mod shop\nenum Answer:\n Yes\n");
let output = compile_project(&entry, &directory.0).unwrap();
assert_eq!(output.files.len(), 4);
assert!(output.compile.diagnostics.is_empty());
let escaped = directory.write("escape.tes", "mod shop\nuse \"../outside.tes\"\n");
let outside = directory.0.parent().unwrap().join("outside.tes");
fs::write(&outside, "mod shop\n").unwrap();
let output = compile_project(&escaped, &directory.0).unwrap();
let _ = fs::remove_file(outside);
assert!(
output
.compile
.diagnostics
.iter()
.any(|diagnostic| diagnostic.code == "M0002")
);
}
#[test]
fn compiles_explicit_path_package_exports_under_a_stable_alias() {
let directory = TestDirectory::new();
let dependency_entry = directory.write(
"common/main.tes",
r"mod common
enum Answer:
Yes
No
record Subject:
enabled: Bool
Yes: Bool?
fn field_shadows_enum_path(Answer Subject) Bool:
Answer.Yes
fixture enabled_subject Subject:
enabled: true
dec outcome: Answer
policy.main @policy:
Shared policy.
ref policy.private
fn active(subject Subject) Bool:
subject.enabled
rule enabled:
Subject.enabled: outcome = Answer.Yes
rule disabled:
not Subject.enabled: outcome = Answer.No
policy.private @internal note:
Package implementation detail.
test dependency_test_is_not_imported:
let subject = Subject { enabled: true }
expect outcome(subject) = Answer.No
",
);
let app_entry = directory.write(
"app/main.tes",
r"mod app
record Subject:
local_only: Bool
test imported_policy_works:
let subject = common.Subject from common.enabled_subject {}
expect common.outcome(subject) = common.Answer.Yes
assert imported_policy_is_total(subject common.Subject):
common.outcome(subject)
rule exported_fragment_basis(subject common.Subject):
basis common.policy.main
subject.enabled: common.outcome(subject) = common.Answer.Yes
",
);
let dependency = PackageDependency {
alias: "common".into(),
entry: dependency_entry,
root: directory.0.join("common"),
exports: BTreeSet::from([
"Answer".into(),
"Subject".into(),
"enabled_subject".into(),
"outcome".into(),
"policy.main".into(),
]),
};
let output =
compile_project_with_dependencies(&app_entry, &directory.0.join("app"), &[dependency])
.unwrap();
assert!(
!output
.compile
.diagnostics
.iter()
.any(|diagnostic| diagnostic.severity == crate::diagnostic::Severity::Error),
"{:?}",
output.compile.diagnostics
);
let program = output.compile.program.unwrap();
assert!(program.entity("common.Subject").is_some());
assert!(program.decision("common.outcome").is_some());
assert!(program.derive("common.policy.main.active").is_some());
assert!(program.rule("common.policy.main.enabled").is_some());
assert_eq!(
program
.fragment("common.policy.main")
.unwrap()
.locator
.value,
"policy"
);
assert_eq!(
program.fragment("common.policy.main").unwrap().refs[0]
.id
.value,
"@pkg_common.policy.private"
);
assert!(program.fragment("@pkg_common.policy.private").is_some());
assert_eq!(program.cases().len(), 1);
let tests = crate::engine::run_tests(&program, None);
assert!(tests.success(), "{}", tests.render_text());
assert!(!tests.render_text().contains("@pkg_"));
let report =
crate::analysis::analyze(&program, None, &crate::analysis::AnalysisOptions::default());
assert!(report.success(), "{}", report.render_text());
}
#[test]
fn private_package_rules_seed_implicit_inputs_from_function_arguments() {
let directory = TestDirectory::new();
let dependency_entry = directory.write(
"common/main.tes",
r"mod common
record Subject:
enabled: Bool
score: Int
fn active(Subject) Bool:
Subject.enabled
fn adjusted(Subject) Int:
Subject.score + 1
dec outcome: Bool
dec total: Int
rule enabled:
active(Subject): outcome = true
rule copied:
: total = adjusted(Subject)
",
);
let app_entry = directory.write("app/main.tes", "mod app\n");
let dependency = PackageDependency {
alias: "common".into(),
entry: dependency_entry,
root: directory.0.join("common"),
exports: BTreeSet::new(),
};
let output =
compile_project_with_dependencies(&app_entry, &directory.0.join("app"), &[dependency])
.unwrap();
assert!(
!output
.compile
.diagnostics
.iter()
.any(|diagnostic| diagnostic.severity == crate::diagnostic::Severity::Error),
"{:?}",
output.compile.diagnostics
);
let program = output.compile.program.unwrap();
for rule_name in ["@pkg_common.enabled", "@pkg_common.copied"] {
let rule = program.rule(rule_name).unwrap();
assert_eq!(rule.parameters.len(), 1);
assert_eq!(rule.parameters[0].ty.value, "@pkg_common.Subject");
}
}
#[test]
fn compiles_transitive_packages_in_the_declaring_packages_namespace() {
let directory = TestDirectory::new();
let utility_entry = directory.write(
"utility/main.tes",
"mod utility\nfn allowed(enabled Bool) Bool:\n enabled\n",
);
let policy_entry = directory.write(
"policy/main.tes",
r"mod policy
enum Answer:
Yes
No
record Subject:
enabled: Bool
dec outcome(subject Subject): Answer
rule enabled(subject Subject):
utility.allowed(subject.enabled): outcome(subject) = Yes
rule disabled(subject Subject):
not utility.allowed(subject.enabled): outcome(subject) = No
",
);
let app_entry = directory.write(
"app/main.tes",
r"mod app
test transitive_rule_bundle_works:
let subject = policy.Subject { enabled: true }
expect policy.outcome(subject) = Yes
",
);
let utility = PackageDependencyGraph {
dependency: PackageDependency {
alias: "utility".into(),
entry: utility_entry,
root: directory.0.join("utility"),
exports: BTreeSet::from(["allowed".into()]),
},
dependencies: Vec::new(),
};
let policy = PackageDependencyGraph {
dependency: PackageDependency {
alias: "policy".into(),
entry: policy_entry,
root: directory.0.join("policy"),
exports: BTreeSet::from(["Answer".into(), "Subject".into(), "outcome".into()]),
},
dependencies: vec![utility],
};
let output =
compile_project_with_dependency_graph(&app_entry, &directory.0.join("app"), &[policy])
.unwrap();
assert!(
!output
.compile
.diagnostics
.iter()
.any(|diagnostic| diagnostic.severity == crate::diagnostic::Severity::Error),
"{:?}",
output.compile.diagnostics
);
let program = output.compile.program.unwrap();
assert!(program.derive("@pkg_policy.utility.allowed").is_some());
assert!(crate::engine::run_tests(&program, None).success());
}
}