aion-cli 0.30.0

The `aion` command line: operate Aion durable workflows over gRPC and run the Aion server.
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
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
//! `aion awl guide` — the language's glossary, at the command line.
//!
//! Every sentence printed here comes from `aion_awl::guide`, the same table
//! the language server hovers and the ops console renders. Nothing in this
//! module writes prose about AWL, and nothing in it may: a `--help` string
//! that explained `distribute` in its own words would be a third account of
//! the language, and the day the language moved it would still read
//! plausibly.
//!
//! The `--json` form is the console's input. It is checked into the console's
//! assets rather than fetched at runtime — the console must teach the
//! language with no server reachable — and
//! `tests/console_guide_asset.rs` refuses any drift between the two.

use std::collections::BTreeMap;
use std::process::ExitCode;

use aion_awl::guide::{self, GrammarForm, GrammarPosition, GrammarRelation, GuideEntry, WordKind};
use anyhow::Context as _;
use serde::Serialize;

/// One glossary entry, as the console reads it.
///
/// `citation` is carried rather than recomputed so that the console prints
/// the byte-identical pointer the CLI prints. Two renderings of one fact are
/// two facts as soon as one of them is edited. `examples` is the entry's
/// complete checked documents, verbatim, one per honest form of the word.
/// `form` and `positions` are the grammar axis, derived from the same table
/// the language server's completion offers words from — the console groups
/// by `form` and looks both up in the document-level `forms` and `positions`
/// inventories. `children`, `siblings` and `parents` are the structural
/// relations, derived from that same table: every id in them is a `word` of
/// another entry, so a surface may render them as links without checking.
/// `at_document_level` is the one thing `parents` cannot say — that what
/// encloses the word is the document itself — and it is carried rather than
/// re-derived from `positions` so the console and the CLI cannot come to
/// disagree about which words are top-level.
#[derive(Debug, Serialize)]
struct WireEntry {
    word: &'static str,
    kind: &'static str,
    summary: &'static str,
    examples: &'static [&'static str],
    section: WireSection,
    citation: String,
    form: &'static str,
    positions: Vec<&'static str>,
    children: Vec<&'static str>,
    siblings: Vec<&'static str>,
    parents: Vec<&'static str>,
    at_document_level: bool,
}

/// The reference section an entry cites.
#[derive(Debug, Serialize)]
struct WireSection {
    number: u8,
    title: &'static str,
}

/// One grammatical form, as the console's grouping axis reads it.
#[derive(Debug, Serialize)]
struct WireForm {
    id: &'static str,
    label: &'static str,
    description: &'static str,
}

/// One grammar position a word may stand in, with its rendered label.
#[derive(Debug, Serialize)]
struct WirePosition {
    id: &'static str,
    label: &'static str,
}

/// One structural relation, with the heading every surface prints over it.
#[derive(Debug, Serialize)]
struct WireRelation {
    id: &'static str,
    label: &'static str,
    description: &'static str,
}

/// The whole glossary: the paths every surface points authors at, the
/// grammar taxonomy in display order, the full reference text, and the
/// entries themselves.
#[derive(Debug, Serialize)]
struct WireGuide {
    guide: &'static str,
    reference: &'static str,
    authoring: &'static str,
    workers: &'static str,
    commands: &'static str,
    declared_commands: &'static str,
    forms: Vec<WireForm>,
    positions: Vec<WirePosition>,
    relations: Vec<WireRelation>,
    entries: Vec<WireEntry>,
    doc_comments: WireDocComments,
    reference_text: &'static str,
}

/// The doc-comment surface: one sentence, then every site with its verdict.
///
/// Rendered from `aion_awl::guide::doc_comment_sites`, which is rendered from
/// the parser's own admission table. The console teaches `///` from THIS, so
/// a site the parser admits and the console refuses cannot exist.
#[derive(Debug, Serialize)]
struct WireDocComments {
    summary: &'static str,
    sites: Vec<WireDocSite>,
}

/// One doc-comment site.
#[derive(Debug, Serialize)]
struct WireDocSite {
    id: &'static str,
    description: &'static str,
    admitted: bool,
    #[serde(skip_serializing_if = "Option::is_none")]
    refusal: Option<String>,
}

impl WireEntry {
    fn of(entry: GuideEntry) -> anyhow::Result<Self> {
        let form = guide::form_of(entry.word()).with_context(|| {
            format!(
                "`{}` has a glossary entry but the grammar table offers it in no position, so it \
                 has no form; place it in `aion_awl::guide::grammar`",
                entry.word()
            )
        })?;
        Ok(Self {
            word: entry.word(),
            kind: entry.kind().as_str(),
            summary: entry.summary(),
            examples: entry.examples(),
            section: WireSection {
                number: entry.section().number(),
                title: entry.section().title(),
            },
            citation: entry.citation(),
            form: form.id(),
            positions: guide::positions_of(entry.word())
                .into_iter()
                .map(GrammarPosition::id)
                .collect(),
            children: entry.children(),
            siblings: entry.siblings(),
            parents: entry.parents(),
            at_document_level: entry.stands_at_the_document_level(),
        })
    }
}

/// The glossary as canonical JSON, ending in a newline.
///
/// Field order is the declaration order of the structs above and entry order
/// is the language's own inventory order, so the same table always serialises
/// to the same bytes — which is what lets the console asset be gated by
/// comparison rather than by inspection.
pub(crate) fn json(word: Option<&str>) -> anyhow::Result<String> {
    let entries = match word {
        Some(word) => guide::for_word(word)
            .into_iter()
            .map(WireEntry::of)
            .collect::<anyhow::Result<Vec<_>>>()?,
        None => guide::all()
            .into_iter()
            .map(WireEntry::of)
            .collect::<anyhow::Result<Vec<_>>>()?,
    };
    let document = WireGuide {
        guide: guide::GUIDE_PATH,
        reference: guide::REFERENCE_PATH,
        authoring: guide::AUTHORING_PATH,
        workers: guide::WORKERS_PATH,
        commands: guide::COMMANDS_PATH,
        declared_commands: guide::DECLARED_COMMANDS_PATH,
        forms: GrammarForm::ALL
            .into_iter()
            .map(|form| WireForm {
                id: form.id(),
                label: form.label(),
                description: form.description(),
            })
            .collect(),
        positions: GrammarPosition::ALL
            .into_iter()
            .map(|position| WirePosition {
                id: position.id(),
                label: position.label(),
            })
            .collect(),
        relations: GrammarRelation::ALL
            .into_iter()
            .map(|relation| WireRelation {
                id: relation.id(),
                label: relation.label(),
                description: relation.description(),
            })
            .collect(),
        entries,
        doc_comments: WireDocComments {
            summary: guide::DOC_COMMENT_SUMMARY,
            sites: guide::doc_comment_sites()
                .into_iter()
                .map(|site| WireDocSite {
                    id: site.id,
                    description: site.description,
                    admitted: site.admitted,
                    refusal: site.refusal,
                })
                .collect(),
        },
        reference_text: guide::reference_text(),
    };
    let mut rendered =
        serde_json::to_string_pretty(&document).context("the glossary could not serialise")?;
    rendered.push('\n');
    Ok(rendered)
}

/// Runs `aion awl guide`.
pub(crate) fn run(word: Option<&str>, json_output: bool, reference: bool) -> ExitCode {
    if reference {
        // The full reference, embedded in this binary at build time — the
        // same file the repository's guide_reference_gate reads, so what an
        // installed binary prints is what the gate verified.
        print!("{}", guide::reference_text());
        return ExitCode::SUCCESS;
    }
    if let Some(word) = word {
        let Some(entry) = guide::for_word(word) else {
            eprintln!(
                "error: `{word}` is not a word of AWL; run `aion awl guide` for every word the \
                 language has"
            );
            return ExitCode::FAILURE;
        };
        if json_output {
            return emit_json(Some(entry.word()));
        }
        print!("{}", render_one(entry));
        return ExitCode::SUCCESS;
    }
    if json_output {
        return emit_json(None);
    }
    print!("{}", render_all());
    ExitCode::SUCCESS
}

fn emit_json(word: Option<&str>) -> ExitCode {
    match json(word) {
        Ok(rendered) => {
            print!("{rendered}");
            ExitCode::SUCCESS
        }
        Err(error) => {
            eprintln!("error: the glossary could not be rendered as JSON: {error:#}");
            ExitCode::FAILURE
        }
    }
}

fn kind_noun(kind: WordKind) -> &'static str {
    match kind {
        WordKind::Reserved => "reserved word",
        WordKind::Positional => "positional word",
    }
}

/// One complete example document, indented four spaces as a display block.
fn indented(example: &str) -> String {
    let mut block = String::new();
    for line in example.lines() {
        if line.is_empty() {
            block.push('\n');
        } else {
            block.push_str("    ");
            block.push_str(line);
            block.push('\n');
        }
    }
    block
}

/// How wide a wrapped word list is allowed to run, in columns.
///
/// A presentation decision of this renderer and nothing else — a step body
/// admits twenty-six words, and the whole list is printed whatever the width
/// is. Stated once here rather than repeated at each call so the three
/// relation rows can never wrap to different measures.
const RELATION_WIDTH: usize = 78;

/// The words a relation names, as the entry's row prints them.
///
/// The parent row is the one place a word list is not only words: a top-level
/// declaration is enclosed by the DOCUMENT, which is no word of the language,
/// and an empty parent list would otherwise read the same as `filter`'s —
/// which means the opposite, that a cue on the line reaches it. So the
/// document is named first, and the enclosing words follow it.
fn relation_items(entry: GuideEntry, relation: GrammarRelation) -> Vec<String> {
    let mut items: Vec<String> = Vec::new();
    if relation == GrammarRelation::Parents && entry.stands_at_the_document_level() {
        items.push("the document".to_owned());
    }
    items.extend(
        relation
            .words_of(entry.word())
            .into_iter()
            .map(str::to_owned),
    );
    items
}

/// One relation as an indented, comma-separated row wrapped under its
/// heading, or nothing at all when the word stands in no such relation.
///
/// An empty row is omitted rather than printed empty: "Inside it" with
/// nothing after it invites a reader to wonder what went missing, when the
/// answer is that the word opens nothing.
fn relation_row(
    entry: GuideEntry,
    relation: GrammarRelation,
    label_width: usize,
) -> Option<String> {
    let items = relation_items(entry, relation);
    if items.is_empty() {
        return None;
    }
    let indent = 2 + label_width + 2;
    let mut lines: Vec<String> = Vec::new();
    let mut line = format!("  {:label_width$}  ", relation.label());
    for (index, item) in items.iter().enumerate() {
        let piece = if index + 1 == items.len() {
            item.clone()
        } else {
            format!("{item},")
        };
        if line.trim_end().len() > indent && line.len() + piece.len() + 1 > RELATION_WIDTH {
            lines.push(line.trim_end().to_owned());
            line = " ".repeat(indent);
        } else if line.len() > indent {
            line.push(' ');
        }
        line.push_str(&piece);
    }
    lines.push(line.trim_end().to_owned());
    Some(lines.join("\n"))
}

/// The three structural rows an entry prints under its sentence, in the
/// taxonomy's own order, each omitted when it names nothing.
fn relation_rows(entry: GuideEntry) -> String {
    let label_width = GrammarRelation::ALL
        .into_iter()
        .map(|relation| relation.label().len())
        .max()
        .unwrap_or_default();
    let rows: Vec<String> = GrammarRelation::ALL
        .into_iter()
        .filter_map(|relation| relation_row(entry, relation, label_width))
        .collect();
    if rows.is_empty() {
        return String::new();
    }
    format!("{}\n", rows.join("\n"))
}

/// One word: its meaning, where it stands in the language's own structure,
/// its complete checked examples indented as blocks — numbered when the word
/// has more than one form — then the citation and how to open the full
/// reference from this binary.
fn render_one(entry: GuideEntry) -> String {
    let numbered = entry.examples().len() > 1;
    let blocks = entry
        .examples()
        .iter()
        .enumerate()
        .map(|(index, example)| {
            if numbered {
                format!("  Example {}:\n\n{}", index + 1, indented(example))
            } else {
                indented(example)
            }
        })
        .collect::<Vec<_>>()
        .join("\n");
    format!(
        "{}  ({})\n  {}\n{}\n{blocks}\n  {}\n  Full reference: {}\n",
        entry.word(),
        kind_noun(entry.kind()),
        entry.summary(),
        relation_rows(entry),
        entry.citation(),
        guide::REFERENCE_COMMAND
    )
}

/// The whole glossary, grouped by the reference section each word belongs to.
///
/// The column width is measured from the words themselves rather than fixed,
/// so a longer word added to the language cannot silently push the summaries
/// out of alignment.
fn render_all() -> String {
    let entries = guide::all();
    let width = entries
        .iter()
        .map(|entry| entry.word().len())
        .max()
        .unwrap_or_default();

    let mut lines: Vec<String> = vec![
        "AWL — every word of the language, and where it is explained in full.".to_owned(),
        "Run `aion awl guide <word>` for one word with a checked example.".to_owned(),
        String::new(),
        format!("  Full reference   {}", guide::REFERENCE_COMMAND),
        format!("  Guide            {}", guide::GUIDE_PATH),
        format!("  Reference        {}", guide::REFERENCE_PATH),
        format!("  Authoring path   {}", guide::AUTHORING_PATH),
        format!("  Workers          {}", guide::WORKERS_PATH),
        format!("  Commands         {}", guide::COMMANDS_PATH),
        format!("  Declared bodies  {}", guide::DECLARED_COMMANDS_PATH),
    ];

    let mut sections: BTreeMap<u8, Vec<GuideEntry>> = BTreeMap::new();
    for entry in entries {
        sections
            .entry(entry.section().number())
            .or_default()
            .push(entry);
    }
    for (number, grouped) in sections {
        let title = grouped.first().map_or("", |entry| entry.section().title());
        lines.push(String::new());
        lines.push(format!("§{number}{title}"));
        for entry in grouped {
            lines.push(format!(
                "  {:width$}  {}",
                entry.word(),
                entry.summary(),
                width = width
            ));
        }
    }
    let mut rendered = lines.join("\n");
    rendered.push('\n');
    rendered
}

#[cfg(test)]
#[path = "awl_guide_tests.rs"]
mod awl_guide_tests;