on master help sign in
Add knot-descriptor: a single owner for the schema-descriptor grammar

The schema-descriptor wire format is a deliberately terse, minimal type syntax — separate from (and not accepted by) the real knot grammar. It had no single owner: its rules (brace/bracket depth, the 2+ space record-field separator, `(Rel T)`, `(A | B)`, `Ctor{..}` payload attachment) were reimplemented by hand at dozens of sites (`schema_descriptor_to_fields`, `adt_ctor_to_knot`, `parse_schema_columns`, `schema_descriptor_to_adt_ctors`, `parse_schema_kind`, …), each a slightly different approximation. This adds the foundation for consolidating them: a small, dependency-free `knot-descriptor` crate that is the single source of truth for the format. - `Desc` / `Scalar`: the parsed AST (scalar, record, relation, variant). - `parse(&str) -> Result<Desc, ParseError>`: a recursive-descent parser for the exact grammar the compiler's emitter produces. - `emit(&Desc) -> String`: the canonical inverse, matching the compiler's emitter byte-for-byte. Byte-exactness is load-bearing: the schema lock compares descriptors with string equality, so a drift between emit and re-emit would read as a phantom schema change. A cross-validation test (`descriptor_roundtrip`) builds real programs, pulls the compiler's emitted descriptors out of `TypeEnv`, and asserts `emit(parse(d)) == d` — locking the two sides to one grammar. No consumer sites are migrated yet; this pass is the parser plus the round-trip proof. Migrating the hand-rolled scanners is the follow-up.

commit0e5cf56978ca9374b5dbc3ffc8963c1f4973ee93 authoriko <ilyakooo0@gmail.com> dateSat 5 Sep 2026, 12:52 (1 month ago) parenta620d5b
6 files changed+476
Cargo.lock+5 Cargo.toml+1 crates/knot-compiler/Cargo.toml+4 crates/knot-compiler/tests/descriptor_roundtrip.rs+84 crates/knot-descriptor/Cargo.toml+6 crates/knot-descriptor/src/lib.rs+376
Cargo.lock+5
@@ -1491,6 +1491,7 @@ dependencies = [
14911491 "gimli",
14921492 "indexmap",
14931493 "knot",
1494+ "knot-descriptor",
14941495 "knot-runtime",
14951496 "libc",
14961497 "object",
@@ -1498,6 +1499,10 @@ dependencies = [
14981499 "z3",
14991500]
15001501
1502+[[package]]
1503+name = "knot-descriptor"
1504+version = "0.1.0"
1505+
15011506[[package]]
15021507name = "knot-jit"
15031508version = "0.1.0"
Cargo.toml+1
@@ -17,6 +17,7 @@ edition = "2024"
1717[workspace.dependencies]
1818libc = "0.2"
1919knot = { path = "crates/knot" }
20+knot-descriptor = { path = "crates/knot-descriptor" }
2021knot-compiler = { path = "crates/knot-compiler" }
2122knot-runtime = { path = "crates/knot-runtime" }
2223knot-jit = { path = "crates/knot-jit" }
crates/knot-compiler/Cargo.toml+4
@@ -40,3 +40,7 @@ cranelift-jit = "0.131"
4040
4141[build-dependencies]
4242blake3 = { version = "1", default-features = false }
43+
44+[dev-dependencies]
45+knot-descriptor = { workspace = true }
46+
crates/knot-compiler/tests/descriptor_roundtrip.rsnew file+84
@@ -0,0 +1,84 @@
1+//! Cross-validation that the `knot-descriptor` parser accepts every schema
2+//! descriptor the compiler emits, and that its `emit` reproduces the compiler's
3+//! bytes exactly. This is what lets the descriptor dialect have a single owner:
4+//! the emitter (here) and the parser (knot-descriptor) are locked to one
5+//! grammar by this test, so the ~dozens of hand-rolled descriptor scanners in
6+//! the runtime can be replaced by `knot_descriptor::parse` without changing the
7+//! wire format.
8+//!
9+//! Byte-exactness matters because the schema lock compares descriptors with
10+//! string equality (`lockfile.rs`): any drift between emit and re-emit would
11+//! read as a phantom schema change.
12+
13+use knot_compiler::types::TypeEnv;
14+
15+/// Build a program's `TypeEnv` (structural pass) then compute its schemas, and
16+/// return every emitted descriptor string.
17+fn emitted_descriptors(src: &str) -> Vec<String> {
18+ let lexer = knot::lexer::Lexer::new(src);
19+ let (tokens, lex) = lexer.tokenize();
20+ assert!(lex.iter().all(|d| d.severity != knot::diagnostic::Severity::Error));
21+ let parser = knot::parser::Parser::new(src.to_string(), tokens);
22+ let (program, parse) = parser.parse_file_expr();
23+ assert!(
24+ parse.iter().all(|d| d.severity != knot::diagnostic::Severity::Error)
25+ );
26+ let mut env = TypeEnv::from_program(&program);
27+ env.compute_schemas(&program);
28+ env.source_schemas.values().cloned().collect()
29+}
30+
31+/// Every compiler-emitted descriptor parses under knot-descriptor and re-emits
32+/// to the identical bytes.
33+fn assert_round_trips(src: &str) {
34+ let descs = emitted_descriptors(src);
35+ assert!(!descs.is_empty(), "test program must declare a source");
36+ for d in &descs {
37+ let parsed = knot_descriptor::parse(d)
38+ .unwrap_or_else(|e| panic!("knot-descriptor rejected compiler output {d:?}: {e}"));
39+ let reemitted = knot_descriptor::emit(&parsed);
40+ assert_eq!(
41+ &reemitted, d,
42+ "re-emitted descriptor differs from compiler's bytes"
43+ );
44+ }
45+}
46+
47+#[test]
48+fn scalar_source() {
49+ assert_round_trips("with {\nRel (Int 1) *n\n}\n(| yield {})");
50+}
51+
52+#[test]
53+fn flat_record() {
54+ assert_round_trips("with {\nR {name Text age (Int 1)}\nRel R *rs\n}\n(| yield {})");
55+}
56+
57+#[test]
58+fn nested_record_and_relation_field() {
59+ assert_round_trips(
60+ "with {\nAddr {city Text zip (Int 1)}\nR {name Text addr Addr tags (Rel Text)}\nRel R *rs\n}\n(| yield {})",
61+ );
62+}
63+
64+#[test]
65+fn enum_and_payload_adts() {
66+ assert_round_trips(
67+ "with {\nColor Red {} Green {} Blue {}\nS {c Color}\nRel S *s\n}\n(| yield {})",
68+ );
69+ assert_round_trips(
70+ "with {\nShape Circle {radius (Int 1)} Rect {w (Int 1) h (Int 1)} Point {}\nRel Shape *shapes\n}\n(| yield {})",
71+ );
72+}
73+
74+#[test]
75+fn kitchen_sink() {
76+ assert_round_trips(
77+ "with {\n\
78+ Addr {city Text zip (Int 1)}\n\
79+ Shape Circle {radius (Int 1)} Rect {w (Int 1) h (Int 1)} Point {}\n\
80+ Reading {name Text addr Addr shape Shape tags (Rel Text) history (Rel {at (Int 1)})}\n\
81+ Rel Reading *readings\n\
82+ }\n(| yield {})",
83+ );
84+}
crates/knot-descriptor/Cargo.tomlnew file+6
@@ -0,0 +1,6 @@
1+[package]
2+name = "knot-descriptor"
3+version.workspace = true
4+edition.workspace = true
5+
6+[dependencies]
crates/knot-descriptor/src/lib.rsnew file+376
@@ -0,0 +1,376 @@
1+//! The schema-descriptor wire format: a *deliberately terse, minimal* type
2+//! syntax — separate from (and not accepted by) the real knot grammar.
3+//!
4+//! A descriptor is a persisted relation's element type rendered name-free, so
5+//! it survives the type being deleted from source. The compiler emits it; the
6+//! runtime reads it to derive the physical storage layout.
7+//!
8+//! Grammar (whitespace-insensitive except where noted):
9+//!
10+//! ```text
11+//! desc := scalar | record | "(Rel " desc ")" | "(" ctor (" | " ctor)* ")"
12+//! record := "{" field (" "+ field)* "}" // fields separated by 2+ spaces
13+//! field := ident " " desc
14+//! ctor := ident | ident record // payload attaches with NO space
15+//! scalar := "Int" | "Float" | "Text" | "Bool" | "Bytes" | "Uuid"
16+//! ```
17+//!
18+//! This crate is the SINGLE owner of that grammar. Both the compiler's emitter
19+//! and the runtime's readers route through here, so the two sides can never
20+//! drift into different approximations of the format. A round-trip property
21+//! (`emit(parse(s)) == s`) locks emitter and parser to one grammar.
22+
23+use std::fmt;
24+
25+/// A parsed schema descriptor.
26+#[derive(Debug, Clone, PartialEq, Eq)]
27+pub enum Desc {
28+ /// A scalar column type.
29+ Scalar(Scalar),
30+ /// `{name Text addr {city Text}}` — an ordered list of named fields.
31+ Record(Vec<(String, Desc)>),
32+ /// `(Rel Text)` — a relation (set) of elements.
33+ Relation(Box<Desc>),
34+ /// `(Red | Blue)` / `(Circle{radius Int} | Point)` — a sum type. Each
35+ /// constructor is a name plus its (possibly empty) payload fields.
36+ Variant(Vec<(String, Vec<(String, Desc)>)>),
37+}
38+
39+/// The scalar column types.
40+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41+pub enum Scalar {
42+ Int,
43+ Float,
44+ Text,
45+ Bool,
46+ Bytes,
47+ Uuid,
48+}
49+
50+impl Scalar {
51+ fn as_str(self) -> &'static str {
52+ match self {
53+ Scalar::Int => "Int",
54+ Scalar::Float => "Float",
55+ Scalar::Text => "Text",
56+ Scalar::Bool => "Bool",
57+ Scalar::Bytes => "Bytes",
58+ Scalar::Uuid => "Uuid",
59+ }
60+ }
61+
62+ fn from_str(s: &str) -> Option<Scalar> {
63+ match s {
64+ "Int" => Some(Scalar::Int),
65+ "Float" => Some(Scalar::Float),
66+ "Text" => Some(Scalar::Text),
67+ "Bool" => Some(Scalar::Bool),
68+ "Bytes" => Some(Scalar::Bytes),
69+ "Uuid" => Some(Scalar::Uuid),
70+ _ => None,
71+ }
72+ }
73+}
74+
75+/// A descriptor parse error: a byte offset plus a human-readable message.
76+#[derive(Debug, Clone, PartialEq, Eq)]
77+pub struct ParseError {
78+ pub pos: usize,
79+ pub msg: String,
80+}
81+
82+impl fmt::Display for ParseError {
83+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
84+ write!(f, "descriptor parse error at byte {}: {}", self.pos, self.msg)
85+ }
86+}
87+
88+impl std::error::Error for ParseError {}
89+
90+/// Parse a schema-descriptor string into a `Desc`.
91+///
92+/// The input is treated as a single descriptor. Leading/trailing whitespace is
93+/// allowed; anything left over after one descriptor is an error.
94+pub fn parse(input: &str) -> Result<Desc, ParseError> {
95+ let mut p = Parser {
96+ chars: input.as_bytes(),
97+ pos: 0,
98+ };
99+ p.skip_ws();
100+ let d = p.parse_desc()?;
101+ p.skip_ws();
102+ if p.pos != p.chars.len() {
103+ return Err(p.err(format!("unexpected trailing input {:?}", p.rest())));
104+ }
105+ Ok(d)
106+}
107+
108+/// Render a `Desc` back to its canonical descriptor string. This is the exact
109+/// inverse of [`parse`] and matches the compiler's emitter byte-for-byte, so
110+/// `emit(parse(s)) == s` for any well-formed descriptor.
111+pub fn emit(d: &Desc) -> String {
112+ let mut out = String::new();
113+ emit_into(d, &mut out);
114+ out
115+}
116+
117+fn emit_into(d: &Desc, out: &mut String) {
118+ match d {
119+ Desc::Scalar(s) => out.push_str(s.as_str()),
120+ Desc::Record(fields) => {
121+ out.push('{');
122+ for (i, (name, fty)) in fields.iter().enumerate() {
123+ if i > 0 {
124+ out.push_str(" ");
125+ }
126+ out.push_str(name);
127+ out.push(' ');
128+ emit_into(fty, out);
129+ }
130+ out.push('}');
131+ }
132+ Desc::Relation(inner) => {
133+ out.push_str("(Rel ");
134+ emit_into(inner, out);
135+ out.push(')');
136+ }
137+ Desc::Variant(ctors) => {
138+ out.push('(');
139+ for (i, (name, fields)) in ctors.iter().enumerate() {
140+ if i > 0 {
141+ out.push_str(" | ");
142+ }
143+ out.push_str(name);
144+ if !fields.is_empty() {
145+ // Payload attaches directly: `Circle{radius Int}`.
146+ emit_into(&Desc::Record(fields.clone()), out);
147+ }
148+ }
149+ out.push(')');
150+ }
151+ }
152+}
153+
154+struct Parser<'a> {
155+ chars: &'a [u8],
156+ pos: usize,
157+}
158+
159+impl<'a> Parser<'a> {
160+ fn err(&self, msg: String) -> ParseError {
161+ ParseError { pos: self.pos, msg }
162+ }
163+
164+ fn rest(&self) -> String {
165+ String::from_utf8_lossy(&self.chars[self.pos..]).into_owned()
166+ }
167+
168+ fn peek(&self) -> Option<u8> {
169+ self.chars.get(self.pos).copied()
170+ }
171+
172+ fn skip_ws(&mut self) {
173+ while matches!(self.peek(), Some(b' ' | b'\t' | b'\n' | b'\r')) {
174+ self.pos += 1;
175+ }
176+ }
177+
178+ /// Skip whitespace, returning how many spaces were skipped. Callers use the
179+ /// count to enforce the record field separator (2+ spaces).
180+ fn skip_ws_count(&mut self) -> usize {
181+ let start = self.pos;
182+ self.skip_ws();
183+ self.pos - start
184+ }
185+
186+ fn expect(&mut self, c: u8) -> Result<(), ParseError> {
187+ if self.peek() == Some(c) {
188+ self.pos += 1;
189+ Ok(())
190+ } else {
191+ Err(self.err(format!(
192+ "expected {:?}, found {:?}",
193+ c as char,
194+ self.peek().map(|b| b as char)
195+ )))
196+ }
197+ }
198+
199+ fn eat_keyword(&mut self, kw: &str) -> bool {
200+ if self.chars[self.pos..].starts_with(kw.as_bytes()) {
201+ // Must not be followed by an identifier char (so `Rel` doesn't
202+ // match a field named `Relay`).
203+ let next = self.chars.get(self.pos + kw.len());
204+ if !matches!(next, Some(b) if b.is_ascii_alphanumeric() || *b == b'_') {
205+ self.pos += kw.len();
206+ return true;
207+ }
208+ }
209+ false
210+ }
211+
212+ fn parse_ident(&mut self) -> Result<String, ParseError> {
213+ let start = self.pos;
214+ while matches!(self.peek(), Some(b) if b.is_ascii_alphanumeric() || b == b'_') {
215+ self.pos += 1;
216+ }
217+ if self.pos == start {
218+ return Err(self.err(format!("expected an identifier, found {:?}", self.rest())));
219+ }
220+ Ok(String::from_utf8_lossy(&self.chars[start..self.pos]).into_owned())
221+ }
222+
223+ fn parse_desc(&mut self) -> Result<Desc, ParseError> {
224+ self.skip_ws();
225+ match self.peek() {
226+ Some(b'{') => self.parse_record().map(Desc::Record),
227+ Some(b'(') => self.parse_paren(),
228+ Some(_) => {
229+ let name = self.parse_ident()?;
230+ match Scalar::from_str(&name) {
231+ Some(s) => Ok(Desc::Scalar(s)),
232+ None => Err(self.err(format!(
233+ "unknown type {name:?} (expected a scalar, record, (Rel ..), or (A | B))"
234+ ))),
235+ }
236+ }
237+ None => Err(self.err("unexpected end of input".to_string())),
238+ }
239+ }
240+
241+ fn parse_record(&mut self) -> Result<Vec<(String, Desc)>, ParseError> {
242+ self.expect(b'{')?;
243+ let mut fields = Vec::new();
244+ self.skip_ws();
245+ if self.peek() == Some(b'}') {
246+ self.pos += 1;
247+ return Ok(fields);
248+ }
249+ loop {
250+ self.skip_ws();
251+ let name = self.parse_ident()?;
252+ // Field name and type are separated by a single space.
253+ self.skip_ws();
254+ let ty = self.parse_desc()?;
255+ fields.push((name, ty));
256+ // A record field boundary is 2+ spaces at depth 0; `}` ends it.
257+ let before = self.pos;
258+ let spaces = self.skip_ws_count();
259+ match self.peek() {
260+ Some(b'}') => {
261+ self.pos += 1;
262+ return Ok(fields);
263+ }
264+ Some(_) if spaces >= 2 => continue,
265+ Some(_) => {
266+ return Err(self.err(format!(
267+ "expected 2+ spaces or '}}' after field (found {:?})",
268+ self.rest()
269+ )));
270+ }
271+ None => {
272+ self.pos = before;
273+ return Err(self.err("unterminated record (missing '}')".to_string()));
274+ }
275+ }
276+ }
277+ }
278+
279+ /// `(Rel <desc>)` or a variant `(A | B)` / `(Circle{..} | Point)`.
280+ fn parse_paren(&mut self) -> Result<Desc, ParseError> {
281+ self.expect(b'(')?;
282+ self.skip_ws();
283+ if self.eat_keyword("Rel") {
284+ let inner = self.parse_desc()?;
285+ self.skip_ws();
286+ self.expect(b')')?;
287+ return Ok(Desc::Relation(Box::new(inner)));
288+ }
289+ // Variant: one or more ctors separated by ` | `.
290+ let mut ctors = Vec::new();
291+ loop {
292+ self.skip_ws();
293+ let name = self.parse_ident()?;
294+ // A payload record attaches with NO intervening space: `Circle{..}`.
295+ let fields = if self.peek() == Some(b'{') {
296+ self.parse_record()?
297+ } else {
298+ Vec::new()
299+ };
300+ ctors.push((name, fields));
301+ self.skip_ws();
302+ if self.peek() == Some(b'|') {
303+ self.pos += 1;
304+ continue;
305+ }
306+ self.expect(b')')?;
307+ return Ok(Desc::Variant(ctors));
308+ }
309+ }
310+}
311+
312+#[cfg(test)]
313+mod tests {
314+ use super::*;
315+
316+ fn roundtrip(s: &str) {
317+ let d = parse(s).unwrap_or_else(|e| panic!("parse failed for {s:?}: {e}"));
318+ let back = emit(&d);
319+ assert_eq!(back, s, "round-trip mismatch for {s:?}");
320+ }
321+
322+ #[test]
323+ fn scalars() {
324+ for s in ["Int", "Float", "Text", "Bool", "Bytes", "Uuid"] {
325+ roundtrip(s);
326+ }
327+ }
328+
329+ #[test]
330+ fn records() {
331+ roundtrip("{name Text}");
332+ roundtrip("{name Text age Int}");
333+ roundtrip("{name Text addr {city Text zip Int}}");
334+ roundtrip("{}"); // empty record
335+ }
336+
337+ #[test]
338+ fn relations() {
339+ roundtrip("(Rel Text)");
340+ roundtrip("(Rel {name Text})");
341+ roundtrip("{tags (Rel Text)}");
342+ roundtrip("{pets (Rel (Rel Int))}");
343+ }
344+
345+ #[test]
346+ fn variants() {
347+ roundtrip("(Red | Blue)");
348+ roundtrip("(Red | Green | Blue)");
349+ roundtrip("(Circle{radius Int} | Point)");
350+ roundtrip("(Circle{radius Int} | Rect{w Int h Int} | Point)");
351+ }
352+
353+ #[test]
354+ fn nested_combinations() {
355+ roundtrip("{name Text status (Active | Inactive) history (Rel {at Int})}");
356+ roundtrip("(Just{v Text} | Nothing)");
357+ }
358+
359+ #[test]
360+ fn rejects_garbage() {
361+ assert!(parse("").is_err());
362+ assert!(parse("Int Extra").is_err());
363+ assert!(parse("{name}").is_err());
364+ assert!(parse("{name Text").is_err());
365+ assert!(parse("(Rel Text").is_err());
366+ assert!(parse("Nope").is_err());
367+ assert!(parse("(Red Blue)").is_err()); // missing ` | ` separator
368+ assert!(parse("Circle{radius Int}").is_err()); // variant must be parenthesized
369+ }
370+
371+ #[test]
372+ fn rejects_unknown_scalar() {
373+ let e = parse("Missing").unwrap_err();
374+ assert!(e.msg.contains("unknown type"), "{e}");
375+ }
376+}