Skip to main content

cortexkit_rich_ui_syntax/
lib.rs

1//! A bounded syntax front end for rich UI language v1.
2//!
3//! Reference discovery walks the returned syntax tree; it never scans strings
4//! from data, Markdown destinations, or failed tags a second time.
5mod expression;
6mod markdown;
7mod parser;
8mod stream;
9#[cfg(feature = "test-vectors")]
10pub mod vectors;
11
12pub use parser::{parse, parse_bytes};
13pub use stream::{
14    Display, DisplayNode, Revision, StreamParser, Work, PENDING_REFERENCE_CHARS,
15    STREAM_CONTEXT_BYTES, TAG_OPENING_BYTES,
16};
17
18/// Identifies the rich UI source dialect accepted by this parser.
19pub const DIALECT: &str = "rich-ui/1";
20pub const PROGRAM_BYTES: usize = 64 * 1024;
21pub const REPLY_BYTES: usize = 256 * 1024;
22pub const AST_NODES: usize = 8192;
23pub const BLOCKS: usize = 2048;
24pub const OUTPUT_SCALARS: usize = 65_536;
25pub const REFERENCES: usize = 64;
26pub const HANDLES: usize = 64;
27pub const DECLARATIONS: usize = 256;
28pub const EXPRESSION_DEPTH: usize = 64;
29pub const TAG_DEPTH: usize = 32;
30pub const MARKDOWN_DEPTH: usize = 16;
31pub const EMPHASIS_DEPTH: usize = 64;
32pub const DIAGNOSTICS: usize = 50;
33
34/// Which grammar a source follows.
35///
36/// Later releases may add modes, so a `match` outside this crate needs a
37/// wildcard arm:
38///
39/// ```
40/// use cortexkit_rich_ui_syntax::Mode;
41/// fn name(mode: Mode) -> &'static str {
42///     match mode {
43///         Mode::Reply => "reply",
44///         Mode::Program => "program",
45///         _ => "other",
46///     }
47/// }
48/// assert_eq!(name(Mode::Reply), "reply");
49/// ```
50///
51/// ```compile_fail,E0004
52/// use cortexkit_rich_ui_syntax::Mode;
53/// fn name(mode: Mode) -> &'static str {
54///     match mode {
55///         Mode::Reply => "reply",
56///         Mode::Program => "program",
57///     }
58/// }
59/// ```
60#[derive(Clone, Copy, Debug, PartialEq, Eq)]
61#[non_exhaustive]
62pub enum Mode {
63    Reply,
64    Program,
65}
66
67/// Original UTF-8 byte offsets, including a leading BOM if one was supplied.
68#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
69pub struct Span {
70    pub start: usize,
71    pub end: usize,
72}
73impl Span {
74    pub fn new(start: usize, end: usize) -> Self {
75        Self { start, end }
76    }
77}
78
79/// A diagnostic's stable code. New codes may be added in minor releases, so
80/// a `match` needs a wildcard arm; [`DiagnosticCode::as_str`] gives the code
81/// as it is written in documentation and logs, such as `E_SYNTAX`.
82///
83/// Naming every code without a wildcard arm does not compile outside this
84/// crate:
85///
86/// ```compile_fail,E0004
87/// use cortexkit_rich_ui_syntax::DiagnosticCode as C;
88/// fn rank(code: C) -> u8 {
89///     match code {
90///         C::Syntax => 0,
91///         C::UnterminatedString => 1,
92///         C::BadEscape => 2,
93///         C::ReservedName => 3,
94///         C::DuplicateName => 4,
95///         C::DeclarationIncomplete => 5,
96///         C::LiteralRequired => 6,
97///         C::LimitDeclarations => 7,
98///         C::DuplicateProp => 8,
99///         C::DuplicateKey => 9,
100///         C::MismatchedClose => 10,
101///         C::UnclosedTag => 11,
102///         C::ReplyForbidden => 12,
103///         C::LimitTags => 13,
104///         C::Nesting => 14,
105///         C::NonAssoc => 15,
106///         C::MixedCoalesce => 16,
107///         C::NumberLiteral => 17,
108///         C::ParseWork => 18,
109///         C::LimitAst => 19,
110///         C::LimitBlocks => 20,
111///         C::LimitText => 21,
112///         C::SourceTooLarge => 22,
113///         C::ReferenceLimit => 23,
114///         C::Encoding => 24,
115///     }
116/// }
117/// ```
118#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
119#[non_exhaustive]
120pub enum DiagnosticCode {
121    /// `E_SYNTAX`: source that fits no rule of the grammar.
122    Syntax,
123    /// `E_UNTERMINATED_STRING`: a string literal that reaches a raw line
124    /// break, or the end of its construct, before its closing quote.
125    UnterminatedString,
126    /// `E_BAD_ESCAPE`: an unknown escape in a string literal, or a `\u{…}`
127    /// escape that is malformed or names no Unicode scalar value.
128    BadEscape,
129    /// `E_RESERVED_NAME`: a declaration whose name is reserved.
130    ReservedName,
131    /// `E_DUPLICATE_NAME`: a second declaration of the same name.
132    DuplicateName,
133    /// `E_DECLARATION_INCOMPLETE`: a declaration whose value expression is
134    /// missing or cut short.
135    DeclarationIncomplete,
136    /// `E_LITERAL_REQUIRED`: a state initializer or handle path that is not
137    /// a literal.
138    LiteralRequired,
139    /// `E_LIMIT_DECLARATIONS`: more than [`DECLARATIONS`] declarations.
140    LimitDeclarations,
141    /// `E_DUPLICATE_PROP`: a tag that sets the same prop twice.
142    DuplicateProp,
143    /// `E_DUPLICATE_KEY`: a record literal that repeats a key.
144    DuplicateKey,
145    /// `E_MISMATCHED_CLOSE`: a closing tag that does not close the open tag.
146    MismatchedClose,
147    /// `E_UNCLOSED_TAG`: a paired tag without its closing tag.
148    UnclosedTag,
149    /// `E_REPLY_FORBIDDEN`: a reply tag prop outside the reply-value grammar.
150    ReplyForbidden,
151    /// `E_LIMIT_TAGS`: more than 64 tags in a reply.
152    LimitTags,
153    /// `E_NESTING`: a nesting limit was exceeded.
154    Nesting,
155    /// `E_NONASSOC`: chained comparison or equality operators.
156    NonAssoc,
157    /// `E_MIXED_COALESCE`: `??` mixed with `&&` or `||` without grouping.
158    MixedCoalesce,
159    /// `E_NUMBER_LITERAL`: a number outside the exact decimal grammar.
160    NumberLiteral,
161    /// `E_PARSE_WORK`: the deterministic parsing work allowance ran out.
162    ParseWork,
163    /// `E_LIMIT_AST`: more than [`AST_NODES`] syntax nodes.
164    LimitAst,
165    /// `E_LIMIT_BLOCKS`: more than [`BLOCKS`] Markdown blocks.
166    LimitBlocks,
167    /// `E_LIMIT_TEXT`: more than [`OUTPUT_SCALARS`] text scalars in a reply.
168    LimitText,
169    /// `E_SOURCE_TOO_LARGE`: a program longer than [`PROGRAM_BYTES`].
170    SourceTooLarge,
171    /// `E_REFERENCE_LIMIT`: more than [`REFERENCES`] references or
172    /// [`HANDLES`] handles.
173    ReferenceLimit,
174    /// `W_ENCODING`: invalid UTF-8 was replaced before parsing.
175    Encoding,
176}
177
178impl DiagnosticCode {
179    /// The code as written in documentation and logs, such as `E_SYNTAX`.
180    pub const fn as_str(self) -> &'static str {
181        match self {
182            Self::Syntax => "E_SYNTAX",
183            Self::UnterminatedString => "E_UNTERMINATED_STRING",
184            Self::BadEscape => "E_BAD_ESCAPE",
185            Self::ReservedName => "E_RESERVED_NAME",
186            Self::DuplicateName => "E_DUPLICATE_NAME",
187            Self::DeclarationIncomplete => "E_DECLARATION_INCOMPLETE",
188            Self::LiteralRequired => "E_LITERAL_REQUIRED",
189            Self::LimitDeclarations => "E_LIMIT_DECLARATIONS",
190            Self::DuplicateProp => "E_DUPLICATE_PROP",
191            Self::DuplicateKey => "E_DUPLICATE_KEY",
192            Self::MismatchedClose => "E_MISMATCHED_CLOSE",
193            Self::UnclosedTag => "E_UNCLOSED_TAG",
194            Self::ReplyForbidden => "E_REPLY_FORBIDDEN",
195            Self::LimitTags => "E_LIMIT_TAGS",
196            Self::Nesting => "E_NESTING",
197            Self::NonAssoc => "E_NONASSOC",
198            Self::MixedCoalesce => "E_MIXED_COALESCE",
199            Self::NumberLiteral => "E_NUMBER_LITERAL",
200            Self::ParseWork => "E_PARSE_WORK",
201            Self::LimitAst => "E_LIMIT_AST",
202            Self::LimitBlocks => "E_LIMIT_BLOCKS",
203            Self::LimitText => "E_LIMIT_TEXT",
204            Self::SourceTooLarge => "E_SOURCE_TOO_LARGE",
205            Self::ReferenceLimit => "E_REFERENCE_LIMIT",
206            Self::Encoding => "W_ENCODING",
207        }
208    }
209}
210
211impl std::fmt::Display for DiagnosticCode {
212    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
213        f.write_str(self.as_str())
214    }
215}
216
217#[derive(Clone, Debug, PartialEq, Eq)]
218pub struct Diagnostic {
219    pub code: DiagnosticCode,
220    pub span: Span,
221    /// The name the diagnostic is about, where it has one: the declaration a
222    /// reserved or duplicate name refused, the repeated prop or record key
223    /// (`children` when a tag both sets that prop and has children), the tag
224    /// left unclosed or wrongly closed, the prop a reply may not carry, the
225    /// state or handle that needs a literal, the tag past the reply's tag
226    /// limit, and the tag nested too deep. Expression nesting, parse-work,
227    /// size and count limits name nothing.
228    pub subject: Option<String>,
229}
230
231#[derive(Clone, Debug, PartialEq, Eq)]
232pub struct Node {
233    pub span: Span,
234    pub kind: Kind,
235    pub children: Vec<Node>,
236}
237
238#[derive(Clone, Debug, PartialEq, Eq)]
239#[non_exhaustive]
240pub enum Kind {
241    Markdown(Markdown),
242    Text(String),
243    /// Opaque source, not reparsed as Markdown or expressions.
244    Literal(String),
245    Reference {
246        n: String,
247    },
248    Handle {
249        kind: String,
250        path: String,
251    },
252    Tag {
253        name: String,
254        block: bool,
255        props: Vec<Prop>,
256    },
257    Interpolation {
258        block: bool,
259    },
260    Declaration {
261        name: String,
262        state: bool,
263    },
264    Expression(Expression),
265}
266
267#[derive(Clone, Debug, PartialEq, Eq)]
268pub struct Prop {
269    pub name: String,
270    pub span: Span,
271    pub value: Node,
272}
273
274#[derive(Clone, Debug, PartialEq, Eq)]
275#[non_exhaustive]
276pub enum Expression {
277    Number(String),
278    String(String),
279    Boolean(bool),
280    Null,
281    Name(String),
282    State(String),
283    Group,
284    List,
285    Record(Vec<String>),
286    Call(String),
287    Unary(String),
288    Binary(String),
289    Conditional,
290    Member(String),
291    Index,
292}
293
294#[derive(Clone, Debug, PartialEq, Eq)]
295#[non_exhaustive]
296pub enum Markdown {
297    Paragraph,
298    Heading(u8),
299    ThematicBreak,
300    CodeBlock {
301        language: String,
302        text: String,
303    },
304    Code(String),
305    Quote,
306    List {
307        start: Option<u64>,
308    },
309    Item,
310    Task(bool),
311    Table,
312    TableHead,
313    TableRow,
314    TableCell,
315    Emphasis,
316    Strong,
317    Strikethrough,
318    /// An inert syntax address, not a validated or authorized navigation target.
319    Link {
320        destination: String,
321        title: String,
322        image: bool,
323    },
324    SoftBreak,
325    HardBreak,
326}
327
328#[derive(Clone, Debug, PartialEq, Eq)]
329pub struct Document {
330    pub mode: Mode,
331    pub nodes: Vec<Node>,
332    pub diagnostics: Vec<Diagnostic>,
333    pub suppressed_diagnostics: usize,
334    pub parsed_bytes: usize,
335    pub shortened: bool,
336    /// A whole-source limit selected bounded literal fallback; no refs survive.
337    pub fallback: bool,
338}
339
340#[derive(Clone, Debug, PartialEq, Eq)]
341pub struct Reference {
342    /// Canonical decimal, not a machine integer: the grammar sets no integer cap.
343    pub n: String,
344    pub span: Span,
345    /// True for every occurrence after the first 64 in source order. A caller
346    /// resolving references copies a tool's output only for occurrences where
347    /// this is false.
348    pub excess: bool,
349}
350
351#[derive(Clone, Debug, PartialEq, Eq)]
352pub struct ScanResult {
353    pub references: Vec<Reference>,
354    pub diagnostics: Vec<Diagnostic>,
355    pub suppressed_diagnostics: usize,
356    pub parsed_bytes: usize,
357    pub shortened: bool,
358    pub fallback: bool,
359}
360
361impl Document {
362    /// Lists all reference nodes, including repeated and over-budget occurrences.
363    pub fn references(&self) -> Vec<Reference> {
364        fn visit(node: &Node, refs: &mut Vec<Reference>) {
365            if let Kind::Reference { n } = &node.kind {
366                refs.push(Reference {
367                    n: n.clone(),
368                    span: node.span,
369                    excess: false,
370                });
371            }
372            if let Kind::Tag { props, .. } = &node.kind {
373                for prop in props {
374                    visit(&prop.value, refs);
375                }
376            }
377            for child in &node.children {
378                visit(child, refs);
379            }
380        }
381        let mut refs = Vec::new();
382        for node in &self.nodes {
383            visit(node, &mut refs);
384        }
385        refs.sort_by_key(|r| r.span.start);
386        for (i, reference) in refs.iter_mut().enumerate() {
387            reference.excess = i >= REFERENCES;
388        }
389        refs
390    }
391}
392
393pub fn scan_references(source: &str, mode: Mode) -> ScanResult {
394    let doc = parse(source, mode);
395    ScanResult {
396        references: doc.references(),
397        diagnostics: doc.diagnostics,
398        suppressed_diagnostics: doc.suppressed_diagnostics,
399        parsed_bytes: doc.parsed_bytes,
400        shortened: doc.shortened,
401        fallback: doc.fallback,
402    }
403}
404
405/// Checks a `token` argument slot without trimming, unescaping, or substring search.
406pub fn check_token_slot(value: &str) -> Option<Reference> {
407    if value.len() > REPLY_BYTES {
408        return None;
409    }
410    let end = reference_end(value, 0)?;
411    (end == value.len()).then(|| Reference {
412        n: value[5..end - 1].to_owned(),
413        span: Span::new(0, end),
414        excess: false,
415    })
416}
417
418pub(crate) fn reference_end(source: &str, start: usize) -> Option<usize> {
419    let tail = source.get(start..)?;
420    let digits = tail.strip_prefix("$$tc(")?;
421    let count = digits.bytes().take_while(u8::is_ascii_digit).count();
422    if count == 0
423        || (count > 1 && digits.starts_with('0'))
424        || digits.as_bytes().get(count) != Some(&b')')
425    {
426        return None;
427    }
428    Some(start + 6 + count)
429}