ridl-cli 0.6.0

The `ridl` command-line toolchain: check, baseline, build, test, fmt, diff, lock, lsp, mcp, and describe, over the shared compiler crates.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
//! `ridl lock` — the one command that writes a package's `interfaces.lock`
//! (lock design §5).
//!
//! Plain `ridl lock` allocates a number to every interface that has none —
//! every declared interface and every service's inline shape whose key has
//! no live entry — and writes each package's own file. It is the only form
//! that allocates: a branch never allocates, and the release recipe or the
//! merge queue runs this on `main` (lock design §4). `--rename OLD=NEW` and
//! `--retire NAME` rewrite one entry each, in place, and never allocate. They
//! are the fix RIDL-409 names, so they run with RIDL-409 present, and `PATH`
//! must then resolve to exactly one package. Any other compile error exits 1
//! and writes nothing, whichever package it is in (plan decision PD-6).
//!
//! The command compiles first, through [`ridlc::compile_workspace`] — no
//! network and no `ridl.lock` round trip — and reads each package's directory
//! and its lock as loaded from a second [`load_workspace`] over the same
//! tree: `ridlc`'s output carries the checked IR and the diagnostics but no
//! package handle, and the two loads read the same files in the same order.
//! One line per change goes to stdout — `allocated Name N`,
//! `renamed Old New N`, `retired Name N` — prefixed with the package
//! directory relative to `PATH` over more than one package (plan decision
//! PD-13); diagnostics go to stderr (ADR-0010 decision 2). The exit codes
//! follow ADR-0010 decision 1: 0 when the file is written or there is nothing
//! to change, 1 on a diagnostic error over the source, 2 on a bad flag or a
//! path or I/O failure.
//!
//! `ridl lock merge BASE OURS THEIRS MARKER_SIZE` is the git merge driver for
//! the file (lock design §6): it reads the three sides, runs
//! [`interface_lock::merge`] — a three-way merge over entries matched by
//! number, with no file access — and writes the result to OURS. Exit 0 when
//! the merge is clean, 1 when entries disagree (OURS then holds git conflict
//! markers around only the disagreeing entries and is RIDL-410 until an
//! author resolves it), 2 when an input cannot be read or does not parse
//! (OURS is then left as it was).

use std::path::{Path, PathBuf};
use std::process::ExitCode;

use ridl_core::diag::{DiagCode, Diagnostic, Severity, render};
use ridl_core::interface_lock::{self, InterfaceLock, InvalidLockKey, LockKey, MergeOutcome};
use ridl_core::lint::drop_allowed_by_default;
use ridl_core::{RidlDatabase, load_workspace};
use ridl_ir::v2;
use rowan::TextRange;

/// One package of the run: its directory, its lock as loaded — the empty
/// lock when the directory has none — and its checked IR.
struct LockedPackage {
    dir: PathBuf,
    lock: InterfaceLock,
    ir: v2::Package,
}

/// Whether no error among `diagnostics` is anything but RIDL-409 — the one
/// error `--rename` and `--retire` run with, and the condition under which
/// `ridl check` still runs its desk check (lock design §4). True when there
/// is no error at all.
pub(crate) fn only_lock_orphans<'a>(diagnostics: impl IntoIterator<Item = &'a Diagnostic>) -> bool {
    diagnostics.into_iter().all(|diagnostic| {
        diagnostic.severity != Severity::Error || diagnostic.code == DiagCode::RIDL_409
    })
}

/// Runs `ridl lock`: plain allocation over every package under `path`, or
/// the `--rename` and `--retire` edits over the one package there.
pub fn run_lock(path: &Path, renames: &[String], retires: &[String]) -> ExitCode {
    // A flag that does not parse is refused before anything is compiled.
    let renames: Vec<(LockKey, LockKey)> =
        match renames.iter().map(|flag| parse_rename(flag)).collect() {
            Ok(renames) => renames,
            Err(message) => return usage_error(&message),
        };
    let retires: Vec<LockKey> = match retires
        .iter()
        .map(|flag| parse_key("--retire", flag))
        .collect()
    {
        Ok(retires) => retires,
        Err(message) => return usage_error(&message),
    };
    let editing = !renames.is_empty() || !retires.is_empty();

    let mut db = RidlDatabase::default();
    let mut output = match ridlc::compile_workspace(&mut db, path) {
        Ok(output) => output,
        Err(err) => {
            eprintln!("error: {err}");
            return ExitCode::from(2);
        }
    };
    // A path inside a workspace member loads the whole workspace, and the
    // command reports on, allocates in and edits that member only (ADR-0024
    // decision 9, as ADR-0026 amends it).
    ridlc::retain_in_report_scope(
        &mut output.diagnostics,
        &output.sources,
        output.report_scope.as_deref(),
    );
    // `ridl lock` applies no `[lints]` levels (ADR-0024 decision 8), so a lint
    // that is `allow` by default is left out of what it renders.
    drop_allowed_by_default(&mut output.diagnostics);
    let has_error = output
        .diagnostics
        .iter()
        .any(|diagnostic| diagnostic.severity == Severity::Error);
    if has_error && !(editing && only_lock_orphans(&output.diagnostics)) {
        eprint!("{}", render(&output.diagnostics, &output.sources));
        return ExitCode::FAILURE;
    }

    let irs: Vec<v2::Package> = output
        .checked
        .into_iter()
        .map(|checked| checked.ir)
        .collect();
    let mut packages = match locked_packages(path, irs, output.report_scope.as_deref()) {
        Ok(packages) => packages,
        Err(err) => {
            eprintln!("error: {err}");
            return ExitCode::from(2);
        }
    };

    let outcome = if editing {
        edit(path, &mut packages, &renames, &retires)
    } else {
        allocate(path, &mut packages)
    };
    if let Err(code) = outcome {
        return code;
    }

    // The edits are the fix RIDL-409 names, so an edit run does not render
    // the diagnostic it was asked to resolve; everything else — a warning, a
    // lint — renders as `ridl check` renders it.
    let shown: Vec<Diagnostic> = if editing {
        output
            .diagnostics
            .into_iter()
            .filter(|diagnostic| diagnostic.code != DiagCode::RIDL_409)
            .collect()
    } else {
        output.diagnostics
    };
    eprint!("{}", render(&shown, &output.sources));
    ExitCode::SUCCESS
}

/// `OLD=NEW`, both lock keys.
fn parse_rename(flag: &str) -> Result<(LockKey, LockKey), String> {
    let Some((old, new)) = flag.split_once('=') else {
        return Err(format!("--rename takes OLD=NEW, got `{flag}`"));
    };
    Ok((parse_key("--rename", old)?, parse_key("--rename", new)?))
}

/// One lock key as a flag spells it: an interface name, or `service:` and a
/// dotted name.
fn parse_key(flag: &str, text: &str) -> Result<LockKey, String> {
    text.parse()
        .map_err(|InvalidLockKey(reason)| format!("{flag}: {reason}"))
}

/// A bad flag: exit 2 with the reason.
fn usage_error(message: &str) -> ExitCode {
    eprintln!("error: {message}");
    ExitCode::from(2)
}

/// Pairs every checked package with its directory and its lock as loaded.
/// The loader is deterministic over one tree, so the packages come back in
/// the order `compile_workspace` checked them. With a `scope` (the member
/// directory a path inside a workspace member names), only the packages
/// under it are kept.
fn locked_packages(
    entry: &Path,
    irs: Vec<v2::Package>,
    scope: Option<&Path>,
) -> std::io::Result<Vec<LockedPackage>> {
    let mut db = RidlDatabase::default();
    let loaded = load_workspace(&mut db, entry)?;
    let handles = loaded.workspace.packages(&db).clone();
    debug_assert_eq!(handles.len(), irs.len());
    Ok(handles
        .iter()
        .zip(irs)
        .map(|(package, ir)| {
            debug_assert_eq!(package.name(&db), &ir.name);
            // The loader keeps only the files directly inside the package
            // directory, so any file's parent is that directory; a single
            // file's parent is its own directory (plan decision PD-8).
            let first = package
                .files(&db)
                .first()
                .expect("a loaded package holds at least one file")
                .path(&db);
            let dir = Path::new(first)
                .parent()
                .filter(|dir| !dir.as_os_str().is_empty())
                .map_or_else(|| PathBuf::from("."), Path::to_path_buf);
            let lock = package
                .lock(&db)
                .as_ref()
                .map_or_else(InterfaceLock::default, |lock| lock.lock.clone());
            LockedPackage { dir, lock, ir }
        })
        .filter(|package| scope.is_none_or(|scope| package.dir.starts_with(scope)))
        .collect())
}

/// Plain `ridl lock`: allocates every provisional shape of every package, in
/// the order of the numbers the checker showed as provisional — byte order of
/// the name from `next` — so the number written is the number the package
/// was checked with. A package with nothing to allocate is not written, so a
/// file that already holds every declaration stays byte for byte as it is.
fn allocate(entry: &Path, packages: &mut [LockedPackage]) -> Result<(), ExitCode> {
    let prefixed = packages.len() > 1;
    for package in packages.iter_mut() {
        let mut provisional: Vec<(u32, LockKey)> = package
            .ir
            .shapes()
            .filter(|shape| shape.interface.provisional)
            .map(|shape| (shape.interface.number, shape_key(&shape)))
            .collect();
        if provisional.is_empty() {
            continue;
        }
        provisional.sort();
        let prefix = if prefixed {
            format!("{}: ", relative_dir(entry, &package.dir))
        } else {
            String::new()
        };
        let mut lines = Vec::with_capacity(provisional.len());
        for (_, key) in provisional {
            let number = package
                .lock
                .allocate(key.clone())
                .expect("a provisional shape has no live entry: the checker read this lock");
            lines.push(format!("{prefix}allocated {key} {number}"));
        }
        write(package)?;
        for line in lines {
            println!("{line}");
        }
    }
    Ok(())
}

/// `--rename` and `--retire` over the one package `entry` resolves to. Every
/// edit is validated and applied in memory first, so a refused flag writes
/// nothing; the file is written once, then each change is reported.
fn edit(
    entry: &Path,
    packages: &mut [LockedPackage],
    renames: &[(LockKey, LockKey)],
    retires: &[LockKey],
) -> Result<(), ExitCode> {
    let [package] = packages else {
        return Err(usage_error(&format!(
            "`--rename` and `--retire` edit one package's `{}`, but `{}` holds {} packages; name \
             the package directory",
            interface_lock::FILE_NAME,
            entry.display(),
            packages.len()
        )));
    };
    let mut lines = Vec::new();
    for (old, new) in renames {
        // The new key must be a declaration without an entry — the shape the
        // checker numbered provisionally. A declaration with an entry, or a
        // name nothing declares, is not a rename target.
        if !is_provisional(&package.ir, new) {
            return Err(usage_error(&format!(
                "--rename {old}={new}: `{new}` is not a declaration without an entry in package \
                 `{}`",
                package.ir.name
            )));
        }
        let number = package
            .lock
            .rename(old, new.clone())
            .map_err(|err| usage_error(&format!("--rename {old}={new}: {err}")))?;
        lines.push(format!("renamed {old} {new} {number}"));
    }
    for key in retires {
        if is_declared(&package.ir, key) {
            return Err(usage_error(&format!(
                "--retire {key}: `{key}` is still declared in package `{}`; remove the declaration \
                 first, or keep the entry live",
                package.ir.name
            )));
        }
        let number = package
            .lock
            .retire(key)
            .map_err(|err| usage_error(&format!("--retire {key}: {err}")))?;
        lines.push(format!("retired {key} {number}"));
    }
    write(package)?;
    for line in lines {
        println!("{line}");
    }
    Ok(())
}

/// Writes the package's lock to its directory; an I/O failure is exit 2.
fn write(package: &LockedPackage) -> Result<(), ExitCode> {
    interface_lock::write(&package.dir, &package.lock).map_err(|err| {
        eprintln!(
            "error: cannot write {}: {err}",
            package.dir.join(interface_lock::FILE_NAME).display()
        );
        ExitCode::from(2)
    })
}

/// The lock key of one shape: the interface's name, or `service:` and the
/// service's dotted name for an inline shape (lock design §3).
pub(crate) fn shape_key(shape: &v2::InterfaceShape<'_>) -> LockKey {
    if shape.is_inline() {
        LockKey::Service(shape.name.to_string())
    } else {
        LockKey::Interface(shape.name.to_string())
    }
}

/// Whether the package declares the shape `key` names, with or without an
/// entry.
fn is_declared(ir: &v2::Package, key: &LockKey) -> bool {
    ir.shapes().any(|shape| shape_key(&shape) == *key)
}

/// Whether the package declares the shape `key` names and it has no entry —
/// the checker gave it a provisional number.
fn is_provisional(ir: &v2::Package, key: &LockKey) -> bool {
    ir.shapes()
        .any(|shape| shape.interface.provisional && shape_key(&shape) == *key)
}

/// The package directory relative to `entry`, for the output prefix over a
/// workspace (plan decision PD-13): `.` for `entry` itself, and the directory
/// as the loader recorded it when it is not under `entry`.
fn relative_dir(entry: &Path, dir: &Path) -> String {
    match dir.strip_prefix(entry) {
        Ok(relative) if relative.as_os_str().is_empty() => ".".to_string(),
        Ok(relative) => relative.display().to_string(),
        Err(_) => dir.display().to_string(),
    }
}

/// Runs `ridl lock merge`: the git merge driver over `base`, `ours` and
/// `theirs`, writing the result to `ours`. An empty BASE — what git passes as
/// `%O` when both sides created the file — reads as `next 1` with no entries;
/// an empty OURS or THEIRS is a file that does not parse.
pub fn run_lock_merge(base: &Path, ours: &Path, theirs: &Path, marker_size: usize) -> ExitCode {
    let sides = (
        read_side(base, true),
        read_side(ours, false),
        read_side(theirs, false),
    );
    let (base, ours_lock, theirs_lock) = match sides {
        (Ok(base), Ok(ours), Ok(theirs)) => (base, ours, theirs),
        (Err(code), _, _) | (_, Err(code), _) | (_, _, Err(code)) => return code,
    };
    let (text, code) = match interface_lock::merge(&base, &ours_lock, &theirs_lock, marker_size) {
        MergeOutcome::Clean(merged) => (merged.render(), ExitCode::SUCCESS),
        MergeOutcome::Conflict { text } => {
            eprintln!(
                "error: {}: the two sides disagree; the disagreeing entries are between git \
                 conflict markers, and the file is malformed (RIDL-410) until they are resolved \
                 by hand",
                ours.display()
            );
            (text, ExitCode::FAILURE)
        }
    };
    if let Err(err) = std::fs::write(ours, text) {
        eprintln!("error: cannot write {}: {err}", ours.display());
        return ExitCode::from(2);
    }
    code
}

/// One side of the merge, parsed. A side that cannot be read, or that does
/// not parse, is exit 2 with the reason: the driver merges tables, and a side
/// that is not one is an input it cannot answer over.
fn read_side(path: &Path, empty_is_default: bool) -> Result<InterfaceLock, ExitCode> {
    let text = std::fs::read_to_string(path).map_err(|err| {
        eprintln!("error: cannot read {}: {err}", path.display());
        ExitCode::from(2)
    })?;
    if empty_is_default && text.trim().is_empty() {
        return Ok(InterfaceLock::default());
    }
    interface_lock::parse(&text).map_err(|err| {
        eprintln!(
            "error: {}:{}: {}",
            path.display(),
            line_of(&text, err.range),
            err.message
        );
        ExitCode::from(2)
    })
}

/// The 1-based line holding the start of `range` in `text`.
fn line_of(text: &str, range: TextRange) -> usize {
    let start = usize::from(range.start()).min(text.len());
    text[..start].matches('\n').count() + 1
}