use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
mod lock;
mod property;
use clap::{Parser, Subcommand};
use ridl_core::diag::{DiagCode, Diagnostic, FileId, Label, Severity, SourceMap, Span, render};
use ridl_core::interface_lock::LockKey;
use ridl_fmt::{FormatOutcome, format};
use ridl_syntax::ast::{AstNode as _, HasName as _, InterfaceMember, Name, SourceFile};
use ridlc::{CliRun, Emit};
use rowan::{TextRange, TextSize};
#[derive(Parser)]
#[command(
name = "ridl",
about = "The RIDL toolchain",
version = env!("RIDL_BUILD_VERSION")
)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Check {
#[arg(default_value = ".")]
path: PathBuf,
#[arg(long)]
frozen: bool,
#[arg(long, value_name = "DIR|FILE")]
baseline: Option<PathBuf>,
#[arg(long, value_enum, default_value_t = CheckFormat::Text)]
format: CheckFormat,
},
Baseline {
#[arg(default_value = ".")]
path: PathBuf,
#[arg(long, value_name = "DIR")]
out: Option<PathBuf>,
},
Build {
#[arg(default_value = ".")]
path: PathBuf,
#[arg(long, default_value = "out")]
out_dir: PathBuf,
#[arg(long, value_delimiter = ',', default_value = "rust")]
emit: Vec<Emit>,
#[arg(long)]
frozen: bool,
},
Test {
#[arg(default_value = ".")]
path: PathBuf,
#[arg(long, default_value_t = 256)]
samples: usize,
#[arg(long, value_enum, default_value = "text")]
format: property::TestFormat,
},
Fmt {
#[arg(default_value = ".")]
path: PathBuf,
#[arg(long)]
check: bool,
},
Diff {
old: Option<PathBuf>,
new: Option<PathBuf>,
#[arg(long, value_enum, default_value = "text")]
format: DiffFormat,
#[arg(long, value_name = "CATEGORY")]
explain: Option<String>,
},
#[command(args_conflicts_with_subcommands = true)]
Lock {
#[arg(default_value = ".")]
path: PathBuf,
#[arg(long, value_name = "OLD=NEW")]
rename: Vec<String>,
#[arg(long, value_name = "NAME")]
retire: Vec<String>,
#[command(subcommand)]
sub: Option<LockCommand>,
},
Lsp,
Mcp,
}
#[derive(Subcommand)]
enum LockCommand {
Merge {
base: PathBuf,
ours: PathBuf,
theirs: PathBuf,
#[arg(value_parser = clap::value_parser!(u16).range(1..))]
marker_size: u16,
},
}
#[derive(Clone, Copy, Debug, clap::ValueEnum)]
enum DiffFormat {
Text,
Json,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
enum CheckFormat {
Text,
Json,
}
fn main() -> ExitCode {
let cli = Cli::parse();
match cli.command {
Command::Check {
path,
frozen,
baseline,
format,
} => run_check(&path, frozen, baseline.as_deref(), format),
Command::Baseline { path, out } => run_baseline(&path, out.as_deref()),
Command::Build {
path,
out_dir,
emit,
frozen,
} => finish(ridlc::run_build(&path, &out_dir, &emit, frozen.into())),
Command::Test {
path,
samples,
format,
} => property::run(&path, samples, format),
Command::Fmt { path, check } => run_fmt(&path, check),
Command::Diff {
old,
new,
format,
explain,
} => match explain {
Some(category) => run_explain(&category),
None => match (old, new) {
(Some(old), Some(new)) => run_diff(&old, &new, format),
_ => {
eprintln!(
"error: `ridl diff` needs both an old and a new input, \
or `--explain <CATEGORY>`"
);
ExitCode::from(2)
}
},
},
Command::Lock {
sub:
Some(LockCommand::Merge {
base,
ours,
theirs,
marker_size,
}),
..
} => lock::run_lock_merge(&base, &ours, &theirs, usize::from(marker_size)),
Command::Lock {
path,
rename,
retire,
sub: None,
} => lock::run_lock(&path, &rename, &retire),
Command::Lsp => run_lsp(),
Command::Mcp => run_mcp(),
}
}
fn run_lsp() -> ExitCode {
let (connection, io_threads) = lsp_server::Connection::stdio();
if let Err(err) =
ridl_lsp::server::run_with_version(connection, Some(env!("RIDL_BUILD_VERSION")))
{
eprintln!("error: {err}");
return ExitCode::from(2);
}
if let Err(err) = io_threads.join() {
eprintln!("error: {err}");
return ExitCode::from(2);
}
ExitCode::SUCCESS
}
fn run_mcp() -> ExitCode {
let runtime = match tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
{
Ok(runtime) => runtime,
Err(err) => {
eprintln!("error: {err}");
return ExitCode::from(2);
}
};
match runtime.block_on(ridl_mcp::serve_stdio_with_version(Some(env!(
"RIDL_BUILD_VERSION"
)))) {
Ok(()) => ExitCode::SUCCESS,
Err(err) => {
eprintln!("error: {err}");
ExitCode::from(2)
}
}
}
fn run_explain(category: &str) -> ExitCode {
match ridl_diff::category_from_word(category) {
Some(category) => {
println!("{}", ridl_diff::category_word(category));
println!("{}", ridl_diff::explain(category));
ExitCode::SUCCESS
}
None => {
eprintln!("error: unknown change category `{category}`");
eprintln!("the categories `ridl diff` reports are:");
for known in ridl_diff::CATEGORIES {
eprintln!(" {}", ridl_diff::category_word(known));
}
ExitCode::from(2)
}
}
}
fn run_diff(old: &Path, new: &Path, format: DiffFormat) -> ExitCode {
let old_side = match load_diff_side(old) {
Ok(side) => side,
Err(code) => return code,
};
let new_side = match load_diff_side(new) {
Ok(side) => side,
Err(code) => return code,
};
let report = ridl_diff::diff_workspaces(
&old_side.packages,
old_side.system.as_ref(),
&new_side.packages,
new_side.system.as_ref(),
);
match format {
DiffFormat::Text => print!("{}", ridl_diff::render_text(&report)),
DiffFormat::Json => println!("{}", ridl_diff::render_json(&report)),
}
match report.verdict {
ridl_diff::Verdict::Breaking => ExitCode::FAILURE,
ridl_diff::Verdict::Compatible | ridl_diff::Verdict::Identical => ExitCode::SUCCESS,
}
}
struct DiffSide {
packages: Vec<ridl_ir::v2::Package>,
system: Option<ridl_ir::v2::System>,
}
fn load_diff_side(entry: &Path) -> Result<DiffSide, ExitCode> {
if is_ir_json(entry) {
return Ok(DiffSide {
packages: load_snapshots(&[entry.to_path_buf()], None)?,
system: None,
});
}
if is_non_json_ir(entry) {
eprintln!(
"error: {}: `ridl diff` compares `.ir.json` snapshots only (ADR-0014 decision 5); \
emit the package with `--emit ir-json` to compare it",
entry.display()
);
return Err(ExitCode::from(2));
}
if entry.is_dir() {
let snapshots = snapshot_files(entry)?;
if !snapshots.is_empty() {
return Ok(DiffSide {
packages: load_snapshots(&snapshots, None)?,
system: None,
});
}
if !is_source_dir(entry) {
if let Some(witness) = first_non_json_ir_in(entry) {
return Err(refuse_artifact_directory(
entry,
&witness,
"`ridl diff` compares `.ir.json` snapshots only (ADR-0014 decision 5); emit \
the packages with `--emit ir-json` to compare them",
));
}
if let Some(nested) = first_nested_snapshot_dir(entry)? {
return Err(refuse_nested_snapshot_directory(
entry,
&nested,
&format!("compare `{}` instead", nested.display()),
));
}
}
}
let mut db = ridl_core::RidlDatabase::default();
match ridlc::compile_workspace(&mut db, entry) {
Ok(output) => {
if output
.diagnostics
.iter()
.any(|diagnostic| diagnostic.severity == ridl_core::diag::Severity::Error)
{
eprint!("{}", render(&output.diagnostics, &output.sources));
return Err(ExitCode::from(2));
}
Ok(DiffSide {
packages: output
.checked
.into_iter()
.map(|checked| checked.ir)
.collect(),
system: output.system,
})
}
Err(err) => {
eprintln!("error: {}: {err}", entry.display());
Err(ExitCode::from(2))
}
}
}
const IR_JSON_SUFFIX: &str = match Emit::IrJson.ir_dump_suffix() {
Some(suffix) => suffix,
None => panic!("`ir-json` is an IR dump"),
};
fn is_ir_json(path: &Path) -> bool {
path.is_file()
&& path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.ends_with(IR_JSON_SUFFIX))
}
fn is_non_json_ir(path: &Path) -> bool {
path.is_file()
&& path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| {
Emit::ir_dump_suffixes()
.any(|(emit, suffix)| emit != Emit::IrJson && name.ends_with(suffix))
})
}
fn is_source_file(path: &Path) -> bool {
path.is_file()
&& path.extension().is_some_and(|extension| {
extension == "typl" || extension == "ridl" || extension == "rsdl"
})
}
fn is_source_dir(dir: &Path) -> bool {
dir.join("ridl.toml").is_file()
|| files_matching(dir, is_source_file).is_ok_and(|files| !files.is_empty())
}
const ORDINAL_CATEGORIES: [ridl_diff::Category; 4] = [
ridl_diff::Category::InteractionInserted,
ridl_diff::Category::InteractionReordered,
ridl_diff::Category::InteractionRemoved,
ridl_diff::Category::ReservedNameRedeclared,
];
fn run_check(path: &Path, frozen: bool, baseline: Option<&Path>, format: CheckFormat) -> ExitCode {
let mut run = match ridlc::run_check(path, frozen.into()) {
Ok(run) => run,
Err(err) => {
eprintln!("error: {err}");
return ExitCode::from(2);
}
};
if lock::only_lock_orphans(&run.diagnostics) {
match baseline_location(path, baseline) {
Ok(Some(location)) => {
if let Err(code) = desk_check(path, &location, baseline.is_some(), &mut run) {
return code;
}
}
Ok(None) => {}
Err(code) => return code,
}
}
finish_check(run, format)
}
fn run_baseline(path: &Path, out: Option<&Path>) -> ExitCode {
let out_dir = out
.map(Path::to_path_buf)
.unwrap_or_else(|| default_baseline_dir(path));
let staging = staging_dir(&out_dir);
let _ = std::fs::remove_dir_all(&staging);
let mut run = match ridlc::run_build(path, &staging, &[Emit::IrJson], false.into()) {
Ok(run) => run,
Err(err) => {
let _ = std::fs::remove_dir_all(&staging);
eprintln!("error: {err}");
return ExitCode::from(2);
}
};
if run.has_error() {
let _ = std::fs::remove_dir_all(&staging);
return finish(Ok(run));
}
let mut refused = false;
for gate in [untombstoned_removals, interface_refusals] {
match gate(path, &out_dir, &staging, &mut run) {
Ok(hit) => refused |= hit,
Err(code) => {
let _ = std::fs::remove_dir_all(&staging);
return code;
}
}
}
if refused {
let _ = std::fs::remove_dir_all(&staging);
return finish(Ok(run));
}
if let Err(err) = publish_baseline(&staging, &out_dir) {
let _ = std::fs::remove_dir_all(&staging);
eprintln!(
"error: cannot publish the baseline to {}: {err}",
out_dir.display()
);
return ExitCode::from(2);
}
finish(Ok(run))
}
fn untombstoned_removals(
entry: &Path,
out_dir: &Path,
staging: &Path,
run: &mut CliRun,
) -> Result<bool, ExitCode> {
if !out_dir.is_dir() {
return Ok(false);
}
let published = load_snapshots(&snapshot_files(out_dir)?, Some(PUBLISHED_PARSE_REMEDY))?;
if published.is_empty() {
return Ok(false);
}
let fresh = load_snapshots(&snapshot_files(staging)?, None)?;
let report = ridl_diff::diff_sets(&published, &fresh);
let mut index: Option<DeclIndex> = None;
let mut refusals = Vec::new();
for change in &report.changes {
let refused = match change.category {
ridl_diff::Category::InteractionRemoved => true,
ridl_diff::Category::ReservedNameRedeclared => {
published_reserves(&published, &change.path)
}
_ => false,
};
if !refused {
continue;
}
let index = index.get_or_insert_with(|| DeclIndex::build(entry));
refusals.push(Diagnostic {
code: DiagCode::RIDL_408,
severity: Severity::Error,
message: untombstoned_removal_message(change, &published),
primary: index.span_of(&change.path, &mut run.sources),
labels: Vec::new(),
fixits: Vec::new(),
});
}
let refused = !refusals.is_empty();
run.diagnostics.extend(refusals);
Ok(refused)
}
fn untombstoned_removal_message(
change: &ridl_diff::Change,
published: &[ridl_ir::v2::Package],
) -> String {
let (shape, name) = shape_and_name(&change.path);
let in_shape = shape.map_or(String::new(), |shape| format!(" in `{shape}`"));
let ordinal = published_ordinal(published, &change.path);
let held = ordinal.map_or(String::new(), |ordinal| format!(" (ordinal {ordinal})"));
let slot = ordinal.map_or("that ordinal".to_string(), |ordinal| {
format!("ordinal {ordinal}")
});
if change.category == ridl_diff::Category::ReservedNameRedeclared {
format!(
"`{name}` is declared again{in_shape}, but the baseline being replaced retires that \
name with `reserved`{held}. A tombstone is a permanent reservation (ridl §11): a \
consumer still holding the old contract would read the new interaction as the \
retired one. Give the new interaction a different name and keep `reserved {name}` \
at {slot}."
)
} else if change.after.is_some() {
format!(
"The source retires `{name}`{in_shape} with a tombstone, but not at the ordinal the \
interaction held{held}. A tombstone must hold the retired interaction's own ordinal \
(ridl §11); otherwise the surviving interactions slide into the freed slot. Move \
`reserved {name}` to {slot}."
)
} else if published_reserves(published, &change.path) {
format!(
"The baseline being replaced records `{name}`{in_shape} as retired{held}, but the \
source has dropped the tombstone. A tombstone is a permanent reservation (ridl \
§11). Put `reserved {name}` back at {slot}."
)
} else {
format!(
"`{name}` is gone from the source but the baseline being replaced still declares \
it{in_shape}{held}. Publishing would free its ordinal for a later interaction to \
reuse, with nothing left to record that it was ever taken. Retire it in place with \
`reserved {name}`."
)
}
}
fn published_interaction<'a>(
published: &'a [ridl_ir::v2::Package],
path: &str,
) -> Option<&'a ridl_ir::v2::Decl> {
let mut parts = path.split('/');
let (Some(pkg), Some(container), Some(name)) = (parts.next(), parts.next(), parts.next())
else {
return None;
};
let package = published.iter().find(|package| package.name == pkg)?;
let shape = package.shapes().find(|shape| shape.name == container)?;
shape
.interface
.interactions
.iter()
.find(|decl| match &decl.kind {
Some(ridl_ir::v2::decl::Kind::ReservedSlot(reserved)) => {
reserved.name.as_deref() == Some(name)
}
Some(_) => decl.name == name,
None => false,
})
}
fn published_reserves(published: &[ridl_ir::v2::Package], path: &str) -> bool {
published_interaction(published, path)
.is_some_and(|decl| matches!(&decl.kind, Some(ridl_ir::v2::decl::Kind::ReservedSlot(_))))
}
fn published_ordinal(published: &[ridl_ir::v2::Package], path: &str) -> Option<u32> {
published_interaction(published, path).map(|decl| decl.ordinal)
}
fn interface_refusals(
entry: &Path,
out_dir: &Path,
staging: &Path,
run: &mut CliRun,
) -> Result<bool, ExitCode> {
let fresh = load_snapshots(&snapshot_files(staging)?, None)?;
let mut index: Option<DeclIndex> = None;
let mut refusals = Vec::new();
for package in &fresh {
for shape in package.shapes() {
if !shape.interface.provisional {
continue;
}
let index = index.get_or_insert_with(|| DeclIndex::build(entry));
refusals.push(Diagnostic {
code: DiagCode::RIDL_411,
severity: Severity::Error,
message: provisional_number_message(&package.name, &shape, entry),
primary: index.shape_span(&package.name, shape.name, &mut run.sources),
labels: Vec::new(),
fixits: Vec::new(),
});
}
}
if out_dir.is_dir() {
let published = load_snapshots(&snapshot_files(out_dir)?, Some(PUBLISHED_PARSE_REMEDY))?;
if !published.is_empty() {
let report = ridl_diff::diff_sets(&published, &fresh);
for change in &report.changes {
let Some((package, shape)) = dropped_number(change, &published, &fresh) else {
continue;
};
refusals.push(Diagnostic {
code: DiagCode::RIDL_412,
severity: Severity::Error,
message: dropped_number_message(&package.name, &shape),
primary: detached_span(),
labels: Vec::new(),
fixits: Vec::new(),
});
}
}
}
let refused = !refusals.is_empty();
run.diagnostics.extend(refusals);
Ok(refused)
}
fn provisional_number_message(
package: &str,
shape: &ridl_ir::v2::InterfaceShape<'_>,
entry: &Path,
) -> String {
let key = lock::shape_key(shape);
format!(
"`{key}` has a provisional interface number ({}) in package `{package}`: no entry in \
`interfaces.lock` records it, and a provisional number is no identity. Run `ridl lock \
{}` to allocate and record the number, then publish.",
shape.interface.number,
entry.display()
)
}
fn dropped_number<'a>(
change: &ridl_diff::Change,
published: &'a [ridl_ir::v2::Package],
fresh: &[ridl_ir::v2::Package],
) -> Option<(&'a ridl_ir::v2::Package, ridl_ir::v2::InterfaceShape<'a>)> {
if change.category != ridl_diff::Category::DeclRemoved
|| change.before.as_deref() != Some("interface")
{
return None;
}
let mut parts = change.path.split('/');
let (Some(pkg), Some(name), None) = (parts.next(), parts.next(), parts.next()) else {
return None;
};
let package = published.iter().find(|package| package.name == pkg)?;
let shape = package.shapes().find(|shape| shape.name == name)?;
let number = shape.interface.number;
if number == 0 {
return None;
}
let retired = fresh
.iter()
.find(|package| package.name == pkg)
.is_some_and(|package| package.retired.iter().any(|entry| entry.number == number));
(!retired).then_some((package, shape))
}
fn dropped_number_message(package: &str, shape: &ridl_ir::v2::InterfaceShape<'_>) -> String {
let key = lock::shape_key(shape);
let number = shape.interface.number;
format!(
"`{key}` holds interface number {number} in the baseline being replaced, in package \
`{package}`, but the fresh snapshot neither declares that number nor retires it. \
Publishing would lose the only record that the number was allocated, and `next` could \
hand it to a later interface. Restore the line `{key} {number}` in the package's \
`interfaces.lock` from version control — `{key} {number} retired` when the interface is \
gone."
)
}
fn staging_dir(out_dir: &Path) -> PathBuf {
let name = out_dir
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("baseline");
out_dir
.parent()
.unwrap_or(Path::new("."))
.join(format!(".{name}.staging"))
}
fn publish_baseline(staging: &Path, out_dir: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(out_dir)?;
let mut published = BTreeSet::new();
for fresh in ir_json_files(staging)? {
let name = fresh
.file_name()
.expect("a listed snapshot path has a file name")
.to_os_string();
std::fs::rename(&fresh, out_dir.join(&name))?;
published.insert(name);
}
for stale in ir_json_files(out_dir)? {
if stale
.file_name()
.is_some_and(|name| !published.contains(name))
{
std::fs::remove_file(stale)?;
}
}
std::fs::remove_dir_all(staging)
}
fn baseline_location(entry: &Path, flag: Option<&Path>) -> Result<Option<PathBuf>, ExitCode> {
match flag {
Some(explicit) if is_non_json_ir(explicit) => {
eprintln!(
"error: the baseline `{}` is not an `.ir.json` snapshot: a baseline stays \
`.ir.json` (ADR-0014 decision 5); publish one with `ridl baseline`",
explicit.display()
);
Err(ExitCode::from(2))
}
Some(explicit) if explicit.exists() => Ok(Some(explicit.to_path_buf())),
Some(explicit) => {
eprintln!(
"error: the baseline `{}` does not exist",
explicit.display()
);
Err(ExitCode::from(2))
}
None => {
let default = default_baseline_dir(entry);
Ok(default.is_dir().then_some(default))
}
}
}
fn default_baseline_dir(entry: &Path) -> PathBuf {
let start = if entry.is_file() {
entry.parent().unwrap_or(Path::new(".")).to_path_buf()
} else {
entry.to_path_buf()
};
let mut cursor = start.as_path();
loop {
if cursor.join("ridl.toml").is_file() {
return cursor.join(".ridl").join("baseline");
}
match cursor.parent() {
Some(parent) => cursor = parent,
None => break,
}
}
start.join(".ridl").join("baseline")
}
fn desk_check(
entry: &Path,
location: &Path,
explicit: bool,
run: &mut CliRun,
) -> Result<(), ExitCode> {
let baseline = load_baseline(location, explicit)?;
if baseline.is_empty() {
return Ok(());
}
let mut db = ridl_core::RidlDatabase::default();
let current: Vec<ridl_ir::v2::Package> = match ridlc::compile_workspace(&mut db, entry) {
Ok(output) => output
.checked
.into_iter()
.map(|checked| checked.ir)
.collect(),
Err(err) => {
eprintln!("error: {}: {err}", entry.display());
return Err(ExitCode::from(2));
}
};
let report = ridl_diff::diff_sets(&baseline, ¤t);
let index = DeclIndex::build(entry);
let mut warnings = Vec::new();
for change in &report.changes {
if !ORDINAL_CATEGORIES.contains(&change.category) {
continue;
}
warnings.push(Diagnostic {
code: DiagCode::RIDL_407,
severity: Severity::Warning,
message: drift_message(change),
primary: index.span_of(&change.path, &mut run.sources),
labels: Vec::new(),
fixits: Vec::new(),
});
}
run.diagnostics.extend(warnings);
rename_labels(&baseline, ¤t, &index, run);
Ok(())
}
fn rename_labels(
baseline: &[ridl_ir::v2::Package],
current: &[ridl_ir::v2::Package],
index: &DeclIndex,
run: &mut CliRun,
) {
let orphans: Vec<(usize, LockKey, String)> = run
.diagnostics
.iter()
.enumerate()
.filter(|(_, diagnostic)| diagnostic.code == DiagCode::RIDL_409)
.filter_map(|(position, diagnostic)| {
let (key, dir) = orphan_entry(&run.sources, diagnostic)?;
Some((position, key, dir))
})
.collect();
for (position, old, dir) in orphans {
let Some(package) = index.package_of_dir(&dir) else {
continue;
};
let Some(published) = baseline
.iter()
.find(|candidate| candidate.name == package)
.and_then(|published| {
published
.shapes()
.find(|shape| lock::shape_key(shape) == old)
})
else {
continue;
};
let candidates: Vec<ridl_ir::v2::InterfaceShape<'_>> = current
.iter()
.find(|candidate| candidate.name == package)
.map(|fresh| {
fresh
.shapes()
.filter(|shape| {
shape.interface.provisional
&& same_shape(published.interface, shape.interface)
})
.collect()
})
.unwrap_or_default();
let [candidate] = candidates.as_slice() else {
continue;
};
let new = lock::shape_key(candidate);
let span = index.shape_span(package, candidate.name, &mut run.sources);
run.diagnostics[position].labels.push(Label {
span,
message: format!(
"same shape as `{old}` in the published baseline: run `ridl lock {dir} --rename \
{old}={new}`"
),
});
}
}
fn orphan_entry(sources: &SourceMap, diagnostic: &Diagnostic) -> Option<(LockKey, String)> {
let path = sources.path(diagnostic.primary.file)?;
let text = sources.text(diagnostic.primary.file)?;
let range = diagnostic.primary.range;
let line = text.get(usize::from(range.start())..usize::from(range.end()))?;
let key = line.split(' ').next()?.parse().ok()?;
Some((key, directory_of(path)))
}
fn same_shape(old: &ridl_ir::v2::Interface, new: &ridl_ir::v2::Interface) -> bool {
fn members(interface: &ridl_ir::v2::Interface) -> Vec<ridl_ir::v2::Decl> {
interface
.interactions
.iter()
.cloned()
.map(|mut decl| {
decl.doc = String::new();
decl.labels = Vec::new();
decl.deprecated = None;
decl
})
.collect()
}
members(old) == members(new)
}
fn directory_of(path: &str) -> String {
match Path::new(path).parent() {
Some(dir) if !dir.as_os_str().is_empty() => dir.to_string_lossy().into_owned(),
_ => ".".to_string(),
}
}
fn drift_message(change: &ridl_diff::Change) -> String {
let (shape, name) = shape_and_name(&change.path);
let in_shape = shape.map_or(String::new(), |shape| format!(" in `{shape}`"));
match change.category {
ridl_diff::Category::InteractionReordered => format!(
"`{name}` has moved{in_shape} since the published baseline{}. Declaration order is \
the wire identity of an interaction (ridl §11), so a consumer built against the \
baseline would now bind this slot to a different interaction — put the declarations \
back in the baseline's order and add new ones at the end",
baseline_position(change),
),
ridl_diff::Category::InteractionInserted => format!(
"`{name}` is declared{in_shape} ahead of interactions the published baseline already \
numbers. An interaction inserted above an existing one shifts every later wire \
identity (ridl §11) — declare it at the end of the body instead",
),
ridl_diff::Category::InteractionRemoved => format!(
"`{name}` is gone{in_shape} but the published baseline still declares it. Deleting \
the line frees its slot and every later interaction slides into a wire identity \
that is not its own (ridl §11) — retire it in place with `reserved {name}`, which \
holds the slot for ever",
),
ridl_diff::Category::ReservedNameRedeclared => format!(
"`{name}` is declared again{in_shape}, and the published baseline retires that name \
with `reserved`. A retired name is a permanent wire reservation (ridl §11) — a \
consumer still holding the old contract would read the new interaction as the \
retired one, so give this interaction a different name",
),
_ => format!(
"`{name}`{in_shape} changed against the published baseline in a way that moves a \
wire identity (ridl §11)"
),
}
}
fn shape_and_name(path: &str) -> (Option<&str>, &str) {
let parts: Vec<&str> = path.split('/').collect();
match parts.as_slice() {
[_package, shape, name] => (Some(shape), name),
_ => (None, parts.last().copied().unwrap_or(path)),
}
}
fn baseline_position(change: &ridl_diff::Change) -> String {
let position = |side: &Option<String>| -> Option<u32> {
side.as_ref()?
.rsplit(' ')
.next()?
.parse()
.ok()
.filter(|slot| *slot > 0)
};
match (position(&change.before), position(&change.after)) {
(Some(was), Some(now)) if was != now => {
format!(" (position {was} there, position {now} here)")
}
_ => String::new(),
}
}
fn load_baseline(location: &Path, explicit: bool) -> Result<Vec<ridl_ir::v2::Package>, ExitCode> {
let files = if location.is_dir() {
let snapshots = snapshot_files(location)?;
if snapshots.is_empty() {
if let Some(witness) = first_non_json_ir_in(location) {
return Err(refuse_artifact_directory(
location,
&witness,
"a baseline stays `.ir.json` (ADR-0014 decision 5); publish one with \
`ridl baseline`",
));
}
if let Some(nested) = first_nested_snapshot_dir(location)? {
return Err(refuse_nested_snapshot_directory(
location,
&nested,
&format!("pass `--baseline {}` instead", nested.display()),
));
}
if explicit {
return Err(refuse_empty_baseline(location));
}
}
snapshots
} else {
vec![location.to_path_buf()]
};
load_snapshots(&files, None)
}
fn refuse_empty_baseline(location: &Path) -> ExitCode {
eprintln!(
"error: the baseline `{}` holds no `.ir.json` snapshot directly inside it; point \
`--baseline` at the directory that holds the snapshots (`ridl baseline` publishes \
them to `.ridl/baseline/` at the workspace root), or publish a first one there with \
`ridl baseline --out {}`",
location.display(),
location.display(),
);
ExitCode::from(2)
}
fn files_matching(dir: &Path, keep: fn(&Path) -> bool) -> std::io::Result<Vec<PathBuf>> {
let mut files: Vec<PathBuf> = std::fs::read_dir(dir)?
.flatten()
.map(|entry| entry.path())
.filter(|path| keep(path))
.collect();
files.sort();
Ok(files)
}
fn ir_json_files(dir: &Path) -> std::io::Result<Vec<PathBuf>> {
files_matching(dir, is_ir_json)
}
fn first_non_json_ir_in(dir: &Path) -> Option<PathBuf> {
files_matching(dir, is_non_json_ir)
.unwrap_or_default()
.into_iter()
.next()
}
fn first_nested_snapshot_dir(dir: &Path) -> Result<Option<PathBuf>, ExitCode> {
let unreadable = |path: &Path, err: &std::io::Error| {
eprintln!("error: cannot read {}: {err}", path.display());
ExitCode::from(2)
};
let mut subdirectories: Vec<PathBuf> = std::fs::read_dir(dir)
.map_err(|err| unreadable(dir, &err))?
.flatten()
.map(|entry| entry.path())
.filter(|path| path.is_dir())
.collect();
subdirectories.sort();
for subdirectory in subdirectories {
if !ir_json_files(&subdirectory)
.map_err(|err| unreadable(&subdirectory, &err))?
.is_empty()
{
return Ok(Some(subdirectory));
}
}
Ok(None)
}
fn refuse_nested_snapshot_directory(dir: &Path, nested: &Path, remedy: &str) -> ExitCode {
eprintln!(
"error: {}: no `.ir.json` snapshot directly inside, but the subdirectory `{}` holds one; \
snapshots are read from one directory, never from the directories below it; {remedy}",
dir.display(),
nested
.file_name()
.unwrap_or(nested.as_os_str())
.to_string_lossy()
);
ExitCode::from(2)
}
fn refuse_artifact_directory(dir: &Path, witness: &Path, expectation: &str) -> ExitCode {
eprintln!(
"error: {}: the directory holds IR artifacts (`{}`) but no `.ir.json` snapshot; \
{expectation}",
dir.display(),
witness
.file_name()
.unwrap_or(witness.as_os_str())
.to_string_lossy()
);
ExitCode::from(2)
}
fn snapshot_files(dir: &Path) -> Result<Vec<PathBuf>, ExitCode> {
ir_json_files(dir).map_err(|err| {
eprintln!(
"error: cannot read the snapshot directory {}: {err}",
dir.display()
);
ExitCode::from(2)
})
}
const PUBLISHED_PARSE_REMEDY: &str = "the file is left as it is, because a record that cannot be \
read cannot be shown safe to replace. If the file is damaged, restore it from version \
control or resolve the merge conflict left in it. If a toolchain with a different IR schema \
wrote it, check the source against it with that toolchain (`ridl check --baseline`), then \
remove the file and run `ridl baseline` with this one";
fn load_snapshots(
files: &[PathBuf],
parse_remedy: Option<&str>,
) -> Result<Vec<ridl_ir::v2::Package>, ExitCode> {
let mut packages = Vec::new();
for file in files {
match ridl_diff::load_ir_json(file) {
Ok(package) => packages.push(package),
Err(err @ ridl_diff::LoadError::Parse(_)) => {
let remedy = parse_remedy.map_or(String::new(), |remedy| format!("; {remedy}"));
eprintln!("error: {}: {err}{remedy}", file.display());
return Err(ExitCode::from(2));
}
Err(err) => {
eprintln!("error: {}: {err}", file.display());
return Err(ExitCode::from(2));
}
}
}
Ok(packages)
}
#[derive(Default)]
struct DeclIndex {
texts: BTreeMap<String, String>,
members: BTreeMap<(String, String, String), (String, TextRange)>,
shapes: BTreeMap<(String, String), (String, TextRange)>,
packages: BTreeMap<String, String>,
}
impl DeclIndex {
fn build(entry: &Path) -> Self {
let mut index = Self::default();
for file in collect_source_files(entry).unwrap_or_default() {
let Ok(text) = std::fs::read_to_string(&file) else {
continue;
};
let path = file.to_string_lossy().into_owned();
let parse = ridl_syntax::parse(&text, ridl_core::profile_of_path(&path));
let Some(source) = SourceFile::cast(parse.syntax()) else {
continue;
};
let Some(package) = package_name(&source) else {
continue;
};
index.packages.insert(directory_of(&path), package.clone());
for shape in source.shapes() {
let Some(name) = shape.identity() else {
continue;
};
let Some(range) = shape.identity_range() else {
continue;
};
index
.shapes
.insert((package.clone(), name.clone()), (path.clone(), range));
index.record_members(&package, &name, &path, &text, shape.members());
}
for service in source
.services()
.filter(|service| service.colon_token().is_some())
{
let Some(dotted) = service.name() else {
continue;
};
let name = dotted.text();
if name.is_empty() {
continue;
}
index.shapes.insert(
(package.clone(), name.clone()),
(path.clone(), dotted.syntax().text_range()),
);
for reference in service.shapes() {
let Some(final_segment) = final_ident(reference.syntax()) else {
continue;
};
index.members.insert(
(package.clone(), name.clone(), final_segment),
(path.clone(), reference.syntax().text_range()),
);
}
}
index.texts.insert(path, text);
}
index
}
fn record_members(
&mut self,
package: &str,
shape: &str,
path: &str,
text: &str,
members: impl Iterator<Item = InterfaceMember>,
) {
for member in members {
let Some(name) = member.name() else { continue };
let Some(member_name) = name_text(&name) else {
continue;
};
self.members.insert(
(package.to_string(), shape.to_string(), member_name),
(path.to_string(), declaration_range(&member, text)),
);
}
}
fn span_of(&self, diff_path: &str, sources: &mut SourceMap) -> Span {
let mut parts = diff_path.split('/');
let (Some(package), Some(shape), Some(member)) = (parts.next(), parts.next(), parts.next())
else {
return detached_span();
};
let key = (package.to_string(), shape.to_string(), member.to_string());
let found = self
.members
.get(&key)
.or_else(|| self.shapes.get(&(key.0, key.1)));
let Some((path, range)) = found else {
return detached_span();
};
let Some(text) = self.texts.get(path) else {
return detached_span();
};
Span {
file: sources.file_id(path, text),
range: *range,
}
}
fn package_of_dir(&self, dir: &str) -> Option<&str> {
self.packages.get(dir).map(String::as_str)
}
fn shape_span(&self, package: &str, shape: &str, sources: &mut SourceMap) -> Span {
let Some((path, range)) = self.shapes.get(&(package.to_string(), shape.to_string())) else {
return detached_span();
};
let Some(text) = self.texts.get(path) else {
return detached_span();
};
Span {
file: sources.file_id(path, text),
range: *range,
}
}
}
fn detached_span() -> Span {
Span {
file: FileId::DETACHED,
range: TextRange::empty(TextSize::new(0)),
}
}
fn declaration_range(member: &InterfaceMember, text: &str) -> TextRange {
let range = member.syntax().text_range();
let start = usize::from(range.start());
let end = usize::from(range.end()).min(text.len());
let trimmed = text
.get(start..end)
.map(|slice| slice.trim_end().len())
.unwrap_or(0);
TextRange::at(range.start(), TextSize::new(trimmed as u32))
}
fn package_name(source: &SourceFile) -> Option<String> {
dotted_text(source.package_decl()?.qualified_name()?.syntax())
}
fn final_ident(node: &ridl_syntax::SyntaxNode) -> Option<String> {
node.descendants_with_tokens()
.filter_map(|element| element.into_token())
.filter(|token| token.kind() == ridl_syntax::SyntaxKind::Ident)
.last()
.map(|token| token.text().to_string())
}
fn name_text(name: &Name) -> Option<String> {
Some(name.ident_token()?.text().to_string())
}
fn dotted_text(node: &ridl_syntax::SyntaxNode) -> Option<String> {
let text: String = node
.children_with_tokens()
.filter_map(|element| element.into_token())
.filter(|token| !token.kind().is_trivia())
.map(|token| token.text().to_string())
.collect();
(!text.is_empty()).then_some(text)
}
fn exit_code(run: &CliRun) -> ExitCode {
if run.has_error() {
ExitCode::FAILURE
} else {
ExitCode::SUCCESS
}
}
fn finish_check(run: CliRun, format: CheckFormat) -> ExitCode {
match format {
CheckFormat::Text => finish(Ok(run)),
CheckFormat::Json => {
let json = ridl_core::diag::to_json(&run.diagnostics, &run.sources);
println!(
"{}",
serde_json::to_string_pretty(&json).expect("diagnostics serialize")
);
exit_code(&run)
}
}
}
fn finish(run: std::io::Result<CliRun>) -> ExitCode {
match run {
Ok(run) => {
eprint!("{}", render(&run.diagnostics, &run.sources));
exit_code(&run)
}
Err(err) => {
eprintln!("error: {err}");
ExitCode::from(2)
}
}
}
fn run_fmt(path: &Path, check: bool) -> ExitCode {
let mut sources = SourceMap::new();
let mut diagnostics = Vec::new();
let mut any_would_change = false;
let mut any_broken = false;
let files = match collect_source_files(path) {
Ok(files) => files,
Err((dir, err)) => {
eprintln!("error: cannot read {}: {err}", dir.display());
return ExitCode::from(2);
}
};
for file in files {
let text = match std::fs::read_to_string(&file) {
Ok(text) => text,
Err(err) => {
eprintln!("error: cannot read {}: {err}", file.display());
return ExitCode::from(2);
}
};
let profile = ridl_core::profile_of_path(&file.to_string_lossy());
match format(&text, profile) {
FormatOutcome::Formatted(formatted) => {
if formatted != text {
any_would_change = true;
if !check && let Err(err) = std::fs::write(&file, &formatted) {
eprintln!("error: cannot write {}: {err}", file.display());
return ExitCode::from(2);
}
}
}
FormatOutcome::ParseErrors(errors) => {
any_broken = true;
let file_id = sources.file_id(&file.to_string_lossy(), &text);
for error in &errors {
diagnostics.push(ridlc::syntax_error_diagnostic(error, file_id));
}
}
}
}
eprint!("{}", render(&diagnostics, &sources));
if any_broken || (check && any_would_change) {
ExitCode::FAILURE
} else {
ExitCode::SUCCESS
}
}
fn collect_source_files(path: &Path) -> Result<Vec<PathBuf>, (PathBuf, std::io::Error)> {
if path.is_file() {
return Ok(vec![path.to_path_buf()]);
}
let mut files = Vec::new();
let mut stack = vec![path.to_path_buf()];
while let Some(dir) = stack.pop() {
let entries = std::fs::read_dir(&dir).map_err(|err| (dir.clone(), err))?;
for entry in entries.flatten() {
let child = entry.path();
if child.is_dir() {
let hidden = child
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.starts_with('.'));
if !hidden {
stack.push(child);
}
} else if child
.extension()
.is_some_and(|ext| ext == "typl" || ext == "ridl" || ext == "rsdl")
{
files.push(child);
}
}
}
files.sort();
Ok(files)
}