//! Shared helpers + canonical schema shapes for the //! typing-surface matrix. //! //! See `tests/typing_surface.rs` for the entry point and //! `docs/handoff/20260515-handoff-12.md` §1 for the design //! rationale. // Helpers below are `pub` so submodules can use them. The // `unreachable_pub` lint trips because Cargo sees the test // binary's crate root as the only consumer, but submodule // access requires `pub`. `dead_code` similarly trips during // incremental work when not every helper has callers yet. #![allow(dead_code, unreachable_pub)] use rdbms_playground::completion::{ Completion, SchemaCache, TableColumn, candidates_at_cursor, }; use rdbms_playground::dsl::parser::parse_command_with_schema; use rdbms_playground::dsl::types::Type; use rdbms_playground::input_render::{ AmbientHint, InputState, ambient_hint, classify_input_with_schema, }; pub mod insert_form_a; pub mod insert_form_b; pub mod insert_form_c; pub mod update_with_where; pub mod update_all_rows; pub mod delete_with_where; pub mod where_expression; pub mod delete_all_rows; pub mod explain; pub mod create_table; pub mod drop_column; pub mod drop_relationship; pub mod add_relationship; pub mod index_ops; pub mod constraints; pub mod rename_change_column; pub mod app_commands; pub mod candidate_ordering; // ========================================================= // Canonical schema shapes (handoff §1 — CANONICAL_SCHEMA_SHAPES) // ========================================================= // // Used in snapshot names so failing cases identify the schema. pub const SHAPE_EMPTY: &str = "empty"; pub const SHAPE_SERIAL_PK: &str = "serial_pk"; pub const SHAPE_TEXT_PK: &str = "text_pk"; pub const SHAPE_MULTI_TABLE: &str = "multi_table"; pub const SHAPE_EVERY_TYPE: &str = "every_type"; /// Empty schema — no tables at all. pub fn schema_empty() -> SchemaCache { SchemaCache::default() } /// Single table with a `serial` PK + two text columns. /// `Customers(id:serial, Name:text, Email:text)`. /// /// Exercises auto-gen-skip behaviour: in Form B `id` is omitted /// from the value list; in Form A the user can list it explicitly. pub fn schema_serial_pk() -> SchemaCache { build_schema(&[( "Customers", &[ ("id", Type::Serial), ("Name", Type::Text), ("Email", Type::Text), ], )]) } /// Single table with a text PK and no auto-gen columns. /// `Items(Code:text, Title:text)`. /// /// Exercises the no-auto-gen path: Form A and Form B both /// require values for every column. pub fn schema_text_pk() -> SchemaCache { build_schema(&[( "Items", &[("Code", Type::Text), ("Title", Type::Text)], )]) } /// Two tables sharing no column names. /// `Customers(id:serial, Name:text)` + /// `Orders(OrderId:serial, CustId:int, Total:real)`. /// /// Exercises column-name-leakage detection: column candidates at /// a position scoped to one table must not include columns from /// the other table. pub fn schema_multi_table() -> SchemaCache { build_schema(&[ ( "Customers", &[("id", Type::Serial), ("Name", Type::Text)], ), ( "Orders", &[ ("OrderId", Type::Serial), ("CustId", Type::Int), ("Total", Type::Real), ], ), ]) } /// One table with one column of every `Type` variant. /// Exercises per-type slot prose at every value position. pub fn schema_every_type() -> SchemaCache { build_schema(&[( "Things", &[ ("k", Type::Int), ("r", Type::Real), ("d", Type::Decimal), ("b", Type::Bool), ("dt", Type::Date), ("ts", Type::DateTime), ("data", Type::Blob), ("sid", Type::ShortId), ("auto", Type::Serial), ("note", Type::Text), ], )]) } fn build_schema(tables: &[(&str, &[(&str, Type)])]) -> SchemaCache { let mut cache = SchemaCache::default(); for (table, cols) in tables { let table_cols: Vec = cols .iter() .map(|(n, t)| TableColumn { name: (*n).to_string(), user_type: *t, }) .collect(); cache.tables.push((*table).to_string()); for c in &table_cols { if !cache.columns.contains(&c.name) { cache.columns.push(c.name.clone()); } } cache.table_columns.insert((*table).to_string(), table_cols); } cache } // ========================================================= // Assessment helper — runs the four typing-surface APIs at a // (input, cursor, schema) cell and packages the results for // snapshot + property assertions. // ========================================================= /// Compact, Debug-printable assessment of the typing surface /// at one cell. #[derive(Debug)] pub struct Assessment { pub input: String, pub cursor: usize, pub state: InputState, pub hint: Option, pub completion: Option, /// Parse result rendered as `Ok()` or /// `Err()` to keep snapshots stable as /// the `Command` AST evolves. pub parse_result: Result, } /// Assess the typing surface at the given cell. pub fn assess(input: &str, cursor: usize, schema: &SchemaCache) -> Assessment { let state = classify_input_with_schema(input, schema); let hint = ambient_hint(input, cursor, None, schema); let completion = candidates_at_cursor(input, cursor, schema); let parse_result = match parse_command_with_schema(input, schema) { Ok(cmd) => Ok(command_kind_label(&cmd)), Err(e) => Err(parse_error_label(&e)), }; Assessment { input: input.into(), cursor, state, hint, completion, parse_result, } } /// Convenience: assess at the end of input. pub fn assess_at_end(input: &str, schema: &SchemaCache) -> Assessment { assess(input, input.len(), schema) } fn command_kind_label(cmd: &rdbms_playground::dsl::Command) -> String { use rdbms_playground::dsl::AppCommand; use rdbms_playground::dsl::Command::*; match cmd { CreateTable { .. } => "CreateTable".into(), DropTable { .. } => "DropTable".into(), AddColumn { .. } => "AddColumn".into(), DropColumn { .. } => "DropColumn".into(), RenameColumn { .. } => "RenameColumn".into(), ChangeColumnType { .. } => "ChangeColumnType".into(), AddRelationship { .. } => "AddRelationship".into(), DropRelationship { .. } => "DropRelationship".into(), AddIndex { .. } => "AddIndex".into(), DropIndex { .. } => "DropIndex".into(), AddConstraint { .. } => "AddConstraint".into(), DropConstraint { .. } => "DropConstraint".into(), ShowTable { .. } => "ShowTable".into(), Insert { .. } => "Insert".into(), Update { .. } => "Update".into(), Delete { .. } => "Delete".into(), ShowData { .. } => "ShowData".into(), Replay { .. } => "Replay".into(), Explain { .. } => "Explain".into(), Select { .. } => "Select".into(), SqlInsert { .. } => "SqlInsert".into(), SqlUpdate { .. } => "SqlUpdate".into(), App(app) => match app { AppCommand::Quit => "App(Quit)".into(), AppCommand::Help => "App(Help)".into(), AppCommand::Rebuild => "App(Rebuild)".into(), AppCommand::Save => "App(Save)".into(), AppCommand::SaveAs => "App(SaveAs)".into(), AppCommand::New => "App(New)".into(), AppCommand::Load => "App(Load)".into(), AppCommand::Export { .. } => "App(Export)".into(), AppCommand::Import { .. } => "App(Import)".into(), AppCommand::Mode { .. } => "App(Mode)".into(), AppCommand::Messages { .. } => "App(Messages)".into(), }, } } fn parse_error_label(err: &rdbms_playground::dsl::ParseError) -> String { use rdbms_playground::dsl::ParseError::*; match err { Empty => "Empty".into(), Invalid { at_eof, .. } => { if *at_eof { "Invalid(at_eof)".into() } else { "Invalid(definite)".into() } } } } // ========================================================= // Property-assertion helpers. // // Inline tests in submodules combine these with insta // snapshots — the snapshot detects drift; the assertions // detect bug-class regressions (no column leakage, hint // surfaces the right column, etc.). // ========================================================= /// Returns the list of candidate texts from the ambient hint, /// or an empty Vec if the hint isn't in Candidates mode. pub fn hint_candidate_texts(a: &Assessment) -> Vec { match &a.hint { Some(AmbientHint::Candidates { items, .. }) => { items.iter().map(|c| c.text.clone()).collect() } _ => Vec::new(), } } /// Returns the list of completion candidate texts (from the /// `candidates_at_cursor` surface), or an empty Vec if there /// is no completion. pub fn completion_candidate_texts(a: &Assessment) -> Vec { a.completion.as_ref().map_or_else(Vec::new, |c| { c.candidates.iter().map(|c| c.text.clone()).collect() }) } /// True if the hint is Prose containing the given substring. pub fn hint_prose_contains(a: &Assessment, needle: &str) -> bool { matches!(&a.hint, Some(AmbientHint::Prose(p)) if p.contains(needle)) } /// True if the hint is Prose; returns the prose string. pub const fn hint_prose(a: &Assessment) -> Option<&str> { match &a.hint { Some(AmbientHint::Prose(p)) => Some(p.as_str()), _ => None, } } /// Assert state matches the given variant (by Debug name). pub fn assert_state(a: &Assessment, expected: &str) { let actual = format!("{:?}", a.state); // DefiniteErrorAt carries a position — compare prefix. assert!( actual.starts_with(expected), "state: expected {expected}, got {actual} (input: {:?}, cursor: {})", a.input, a.cursor, ); } /// Assert that no candidate (either hint Candidates or /// completion) contains any of the given names. Used to detect /// column-name leakage from foreign tables. pub fn assert_no_candidate_named(a: &Assessment, forbidden: &[&str]) { let hint_cands = hint_candidate_texts(a); let comp_cands = completion_candidate_texts(a); for name in forbidden { assert!( !hint_cands.iter().any(|c| c == name), "hint candidates contain forbidden {name:?}: {hint_cands:?} (input: {:?})", a.input, ); assert!( !comp_cands.iter().any(|c| c == name), "completion candidates contain forbidden {name:?}: {comp_cands:?} (input: {:?})", a.input, ); } } /// Assert that at least one candidate matches each expected name. pub fn assert_candidate_present(a: &Assessment, expected: &[&str]) { let hint_cands = hint_candidate_texts(a); let comp_cands = completion_candidate_texts(a); let all: Vec<&String> = hint_cands.iter().chain(comp_cands.iter()).collect(); for name in expected { assert!( all.iter().any(|c| *c == name), "expected candidate {name:?} not present. hint: {hint_cands:?}, completion: {comp_cands:?} (input: {:?})", a.input, ); } } /// Snapshot helper — takes a stable name + the Assessment. /// Wraps `insta::assert_debug_snapshot!` with a deterministic /// snapshot suffix so each cell has its own file. #[macro_export] macro_rules! snap { ($name:expr, $assessment:expr) => { ::insta::with_settings!({ snapshot_suffix => $name, description => format!("input={:?} cursor={}", $assessment.input, $assessment.cursor), }, { ::insta::assert_debug_snapshot!(&$assessment); }); }; } // ========================================================= // Infrastructure smoke tests — verify the helper itself // behaves before submodules rely on it. // ========================================================= #[test] fn smoke_each_schema_shape_builds() { // Each canonical schema must produce a SchemaCache the // walker can probe without panicking. Run a trivial // assess() against each. for (label, schema) in [ (SHAPE_EMPTY, schema_empty()), (SHAPE_SERIAL_PK, schema_serial_pk()), (SHAPE_TEXT_PK, schema_text_pk()), (SHAPE_MULTI_TABLE, schema_multi_table()), (SHAPE_EVERY_TYPE, schema_every_type()), ] { let a = assess("insert", 6, &schema); // `insert` alone is incomplete regardless of schema — // a useful invariant to pin so any regression in the // entry-word path surfaces immediately. assert!( matches!(a.state, InputState::IncompleteAtEof), "{label}: expected IncompleteAtEof for 'insert', got {:?}", a.state, ); } } #[test] fn smoke_assess_at_end_returns_each_field() { let schema = schema_serial_pk(); let a = assess_at_end("insert into Customers values (", &schema); // The four surfaces are populated: assert_eq!(a.input, "insert into Customers values ("); assert_eq!(a.cursor, "insert into Customers values (".len()); // The walker recognises this as incomplete (more tokens // wanted), not a definite error. assert!(matches!(a.state, InputState::IncompleteAtEof)); // The hint surfaces per-column prose for the first // non-auto-gen column (Name). assert!( hint_prose_contains(&a, "Name"), "expected hint prose to mention `Name`, got {:?}", a.hint, ); } #[test] fn smoke_assess_parse_label_round_trips() { let schema = schema_serial_pk(); let a = assess_at_end( "insert into Customers values ('Alice', 'a@b.c')", &schema, ); assert_eq!(a.parse_result.as_deref(), Ok("Insert")); assert!(matches!(a.state, InputState::Valid)); }