style: format the whole tree with cargo fmt (stock defaults, #35)

One-time, mechanical reformat — no functional changes. The tree was not
rustfmt-clean (~1800 hunks across ~100 files); this brings it to stock
`cargo fmt` defaults so a `cargo fmt --check` CI gate can follow.
Behaviour-preserving: 2509 pass / 0 fail / 1 ignored (unchanged baseline),
clippy clean. A .git-blame-ignore-revs entry follows so `git blame`
skips this commit.
This commit is contained in:
claude@clouddev1
2026-06-17 21:39:19 +00:00
parent e9606b5f6d
commit 41b7e9a049
102 changed files with 8017 additions and 4975 deletions
+210 -188
View File
@@ -509,7 +509,10 @@ pub enum LoadPickerSubMode {
/// Switched to via `b`. Same input/cursor surface as
/// `PathEntryModal`; kept inline so the picker can flip
/// back to List with `Esc`.
PathEntry { input: String, cursor: usize },
PathEntry {
input: String,
cursor: usize,
},
}
const PAGE_SCROLL_LINES: usize = 5;
@@ -697,9 +700,7 @@ impl App {
// `trimmed[1..].trim()`.
let leading_ws = self.input.len() - self.input.trim_start().len();
let mut offset = leading_ws + 1; // past the `:`
while offset < self.input.len()
&& self.input.as_bytes()[offset].is_ascii_whitespace()
{
while offset < self.input.len() && self.input.as_bytes()[offset].is_ascii_whitespace() {
offset += 1;
}
let view = &self.input[offset..];
@@ -727,8 +728,7 @@ impl App {
pub fn input_validity_verdict(&self) -> Option<crate::dsl::walker::Severity> {
let mode = match self.effective_mode() {
EffectiveMode::Simple => Mode::Simple,
EffectiveMode::AdvancedPersistent
| EffectiveMode::AdvancedOneShot => Mode::Advanced,
EffectiveMode::AdvancedPersistent | EffectiveMode::AdvancedOneShot => Mode::Advanced,
};
// Strip the `:` one-shot prefix so the walker verdicts the SQL
// itself, not the escape marker (which it can't parse).
@@ -1037,10 +1037,7 @@ impl App {
Vec::new()
}
AppEvent::ExportSucceeded { path } => {
self.note_system(crate::t!(
"project.export_ok",
path = path.display()
));
self.note_system(crate::t!("project.export_ok", path = path.display()));
Vec::new()
}
AppEvent::ExportFailed { error } => {
@@ -1056,11 +1053,7 @@ impl App {
// `[ok] replay — N command(s)` summary is payload-bearing
// (the count) and stays.
self.mark_oldest_pending_echo(EchoStatus::Ok);
self.note_system(crate::t!(
"replay.completed",
path = path,
count = count
));
self.note_system(crate::t!("replay.completed", path = path, count = count));
// ADR-0034: surface `[skip]` warnings for app-lifecycle
// commands whose omission can leave the replayed state
// incomplete (`import`, nested `replay`).
@@ -1084,11 +1077,7 @@ impl App {
// it, mirroring how the interactive `running: …`
// path renders source-line context above an error.
if line_number == 0 {
self.note_error(crate::t!(
"replay.failed_open",
path = path,
error = error
));
self.note_error(crate::t!("replay.failed_open", path = path, error = error));
} else {
self.note_error(crate::t!(
"replay.failed_at_line",
@@ -1097,10 +1086,7 @@ impl App {
error = error
));
if !command.is_empty() {
self.note_error(crate::t!(
"replay.command_echo",
command = command
));
self.note_error(crate::t!("replay.command_echo", command = command));
}
}
Vec::new()
@@ -1237,8 +1223,7 @@ impl App {
// stays F1-only.
let hint_key = key.code == KeyCode::F(1)
|| (self.demo_mode
&& (key.code, key.modifiers)
== (KeyCode::Char('g'), KeyModifiers::CONTROL));
&& (key.code, key.modifiers) == (KeyCode::Char('g'), KeyModifiers::CONTROL));
if hint_key {
if self.input.trim().is_empty() {
self.note_hint_for_recent_error();
@@ -1369,8 +1354,8 @@ impl App {
/// against crossterm 0.29). Only active in demo mode (the caller
/// gates on `self.demo_mode`).
fn handle_demo_caption_key(&mut self, key: KeyEvent) -> Option<Vec<Action>> {
let is_toggle = key.code == KeyCode::Char('5')
&& key.modifiers.contains(KeyModifiers::CONTROL);
let is_toggle =
key.code == KeyCode::Char('5') && key.modifiers.contains(KeyModifiers::CONTROL);
if self.demo_caption_capturing {
if is_toggle {
@@ -1379,8 +1364,7 @@ impl App {
self.demo_caption_capturing = false;
let text = std::mem::take(&mut self.demo_caption_buffer);
let trimmed = text.trim();
self.demo_caption =
(!trimmed.is_empty()).then(|| trimmed.to_string());
self.demo_caption = (!trimmed.is_empty()).then(|| trimmed.to_string());
} else {
match key.code {
// Plain characters accumulate invisibly; the prompt
@@ -1553,7 +1537,10 @@ impl App {
&self.schema_cache,
self.effective_mode().as_mode(),
)?;
comp.replaced_range = (comp.replaced_range.0 + offset, comp.replaced_range.1 + offset);
comp.replaced_range = (
comp.replaced_range.0 + offset,
comp.replaced_range.1 + offset,
);
Some(comp)
}
@@ -1581,8 +1568,7 @@ impl App {
idx: usize,
) -> crate::completion::LastCompletion {
let inserted = comp.candidates[idx].text.clone();
let original_text =
self.input[comp.replaced_range.0..comp.replaced_range.1].to_string();
let original_text = self.input[comp.replaced_range.0..comp.replaced_range.1].to_string();
self.input
.replace_range(comp.replaced_range.0..comp.replaced_range.1, &inserted);
let new_end = comp.replaced_range.0 + inserted.len();
@@ -1777,7 +1763,10 @@ impl App {
// teaching echo (ADR-0038) on an advanced effective mode.
let (submission_mode, effective_input) =
if self.mode == Mode::Simple && trimmed.starts_with(':') {
(EffectiveMode::AdvancedOneShot, trimmed[1..].trim().to_string())
(
EffectiveMode::AdvancedOneShot,
trimmed[1..].trim().to_string(),
)
} else if self.mode == Mode::Advanced {
(EffectiveMode::AdvancedPersistent, trimmed.to_string())
} else {
@@ -1845,11 +1834,7 @@ impl App {
/// simple and advanced modes; the parse-first refactor
/// (round-5) routes app commands here before the
/// mode-specific DSL/SQL paths.
fn dispatch_app_command(
&mut self,
cmd: crate::dsl::AppCommand,
source: &str,
) -> Vec<Action> {
fn dispatch_app_command(&mut self, cmd: crate::dsl::AppCommand, source: &str) -> Vec<Action> {
use crate::dsl::{AppCommand, MessagesValue, ModeValue};
debug!(command = ?cmd, "dispatch app command");
match cmd {
@@ -2009,11 +1994,8 @@ impl App {
// mode so the walker gates SQL-only forms — simple-mode
// `select` returns the "this is SQL" hint as a normal
// parse error and is rendered through the Err arm below.
match crate::dsl::parser::parse_command_with_schema_in_mode(
input,
&self.schema_cache,
mode,
) {
match crate::dsl::parser::parse_command_with_schema_in_mode(input, &self.schema_cache, mode)
{
Ok(Command::Replay { path }) => {
// `replay` is parsed as a DSL command for the
// sake of grammar uniformity, but its execution
@@ -2127,15 +2109,9 @@ impl App {
.get(..*position)
.map_or(*position, |s| s.chars().count());
let pad = prefix.chars().count() + chars_before;
self.note_error(crate::t!(
"parse.caret",
padding = " ".repeat(pad)
));
self.note_error(crate::t!("parse.caret", padding = " ".repeat(pad)));
}
self.note_error(crate::t!(
"parse.error",
detail = parse_error_message(&err)
));
self.note_error(crate::t!("parse.error", detail = parse_error_message(&err)));
// ADR-0033 Amendment 3: combine the DSL error with a
// pointer to advanced mode when the same line would
// run as SQL there. Only in simple mode (a one-shot
@@ -2228,7 +2204,11 @@ impl App {
| Command::AddRelationship { .. }
| Command::DropRelationship { .. }
) {
debug!(verb = command.verb(), width = self.last_output_width, "render: relationship diagrams (ADR-0044)");
debug!(
verb = command.verb(),
width = self.last_output_width,
"render: relationship diagrams (ADR-0044)"
);
for line in crate::output_render::render_structure_with_diagrams(
desc,
self.last_output_width,
@@ -2252,11 +2232,7 @@ impl App {
}
}
fn handle_dsl_explain_success(
&mut self,
command: &Command,
plan: &crate::db::QueryPlan,
) {
fn handle_dsl_explain_success(&mut self, command: &Command, plan: &crate::db::QueryPlan) {
self.note_ok_summary(command);
// ADR-0028 §3: the display SQL, then the plan tree.
// `render_explain_plan` returns ready-built `OutputLine`s
@@ -2350,11 +2326,7 @@ impl App {
}
}
fn handle_dsl_add_column_success(
&mut self,
command: &Command,
result: AddColumnResult,
) {
fn handle_dsl_add_column_success(&mut self, command: &Command, result: AddColumnResult) {
self.note_ok_summary(command);
// ADR-0018 §9 / ADR-0038 §6 category 3: emit auto-fill note(s)
// before the structure render so the pedagogical "the tool did
@@ -2369,19 +2341,12 @@ impl App {
self.current_table = Some(result.description);
}
fn handle_dsl_drop_column_success(
&mut self,
command: &Command,
result: DropColumnResult,
) {
fn handle_dsl_drop_column_success(&mut self, command: &Command, result: DropColumnResult) {
self.note_ok_summary(command);
// ADR-0025: when `--cascade` removed covering indexes,
// name each one so the learner sees the side effect.
for index in &result.dropped_indexes {
self.note_system(crate::t!(
"ok.index_dropped_with_column",
index = index,
));
self.note_system(crate::t!("ok.index_dropped_with_column", index = index,));
}
for line in crate::output_render::render_structure(&result.description) {
self.note_system(line);
@@ -2415,10 +2380,7 @@ impl App {
lossy = note.lossy
)
} else {
crate::t!(
"client_side.transformed",
count = note.transformed
)
crate::t!("client_side.transformed", count = note.transformed)
};
self.push_category_three_prose(line);
}
@@ -2583,9 +2545,7 @@ impl App {
(Operation::RenameTable, Some(table.as_str()), None)
}
},
C::SqlCreateTable { name, .. } => {
(Operation::CreateTable, Some(name.as_str()), None)
}
C::SqlCreateTable { name, .. } => (Operation::CreateTable, Some(name.as_str()), None),
C::DropTable { name } => (Operation::DropTable, Some(name.as_str()), None),
C::SqlDropTable { name, .. } => (Operation::DropTable, Some(name.as_str()), None),
C::AddColumn { table, column, .. } => (
@@ -2635,9 +2595,7 @@ impl App {
// SQL `CREATE [UNIQUE] INDEX` shares the add-index operation
// (it reuses `do_add_index`); route engine/validation errors
// through it with the parsed table.
C::SqlCreateIndex { table, .. } => {
(Operation::AddIndex, Some(table.as_str()), None)
}
C::SqlCreateIndex { table, .. } => (Operation::AddIndex, Some(table.as_str()), None),
C::AddConstraint { table, column, .. } => (
Operation::AddConstraint,
Some(table.as_str()),
@@ -2700,19 +2658,13 @@ impl App {
// `dispatch_input` routes them through
// `dispatch_app_command` before the DSL execution
// pipeline that this context builder feeds.
C::App(_) => unreachable!(
"App commands are dispatched before reaching dsl execution"
),
C::App(_) => unreachable!("App commands are dispatched before reaching dsl execution"),
};
TranslateContext {
operation: Some(operation),
table: facts
.table
.or_else(|| fallback_table.map(str::to_string)),
column: facts
.column
.or_else(|| fallback_column.map(str::to_string)),
table: facts.table.or_else(|| fallback_table.map(str::to_string)),
column: facts.column.or_else(|| fallback_column.map(str::to_string)),
child_table: facts.child_table,
parent_table: facts.parent_table,
parent_column: facts.parent_column,
@@ -2818,11 +2770,7 @@ impl App {
}
}
fn handle_path_entry_key(
&mut self,
key: KeyEvent,
mut state: PathEntryModal,
) -> Vec<Action> {
fn handle_path_entry_key(&mut self, key: KeyEvent, mut state: PathEntryModal) -> Vec<Action> {
match key.code {
KeyCode::Esc => {
self.modal = None;
@@ -2904,11 +2852,7 @@ impl App {
}
}
fn handle_load_picker_key(
&mut self,
key: KeyEvent,
mut state: LoadPickerModal,
) -> Vec<Action> {
fn handle_load_picker_key(&mut self, key: KeyEvent, mut state: LoadPickerModal) -> Vec<Action> {
match &mut state.sub_mode {
LoadPickerSubMode::List => match key.code {
KeyCode::Esc => {
@@ -3198,7 +3142,10 @@ impl App {
.map(|c| c.text.clone())
.collect::<Vec<_>>()
.join(", ");
self.push_category_three_prose(crate::t!("hint.ambient_expected", expected = names));
self.push_category_three_prose(crate::t!(
"hint.ambient_expected",
expected = names
));
}
None => self.note_getting_started(),
}
@@ -3413,10 +3360,7 @@ fn render_usage_block(input: &str, mode: Mode) -> String {
.into_iter()
.map(|w| format!("`{w}`"))
.collect();
crate::t!(
"parse.available_commands",
commands = names.join(", ")
)
crate::t!("parse.available_commands", commands = names.join(", "))
}
fn render_cascade_effect(effect: &CascadeEffect) -> String {
@@ -3424,9 +3368,7 @@ fn render_cascade_effect(effect: &CascadeEffect) -> String {
let action_key = match effect.action {
ReferentialAction::Cascade => "db.cascade.action_deleted",
ReferentialAction::SetNull => "db.cascade.action_set_null",
ReferentialAction::Restrict | ReferentialAction::NoAction => {
"db.cascade.action_blocked"
}
ReferentialAction::Restrict | ReferentialAction::NoAction => "db.cascade.action_blocked",
};
crate::t!(
"db.cascade.summary",
@@ -3464,7 +3406,10 @@ mod tests {
fn demo_badge_label_maps_the_invisible_keys() {
let none = KeyModifiers::NONE;
assert_eq!(demo_badge_label(&ke(KeyCode::Tab, none)), Some("[TAB]"));
assert_eq!(demo_badge_label(&ke(KeyCode::BackTab, KeyModifiers::SHIFT)), Some("[SHIFT-TAB]"));
assert_eq!(
demo_badge_label(&ke(KeyCode::BackTab, KeyModifiers::SHIFT)),
Some("[SHIFT-TAB]")
);
assert_eq!(demo_badge_label(&ke(KeyCode::Enter, none)), Some("[ENTER]"));
assert_eq!(demo_badge_label(&ke(KeyCode::Esc, none)), Some("[ESC]"));
assert_eq!(demo_badge_label(&ke(KeyCode::Up, none)), Some("[UP]"));
@@ -3474,8 +3419,14 @@ mod tests {
assert_eq!(demo_badge_label(&ke(KeyCode::Home, none)), Some("[HOME]"));
assert_eq!(demo_badge_label(&ke(KeyCode::End, none)), Some("[END]"));
assert_eq!(demo_badge_label(&ke(KeyCode::PageUp, none)), Some("[PGUP]"));
assert_eq!(demo_badge_label(&ke(KeyCode::PageDown, none)), Some("[PGDN]"));
assert_eq!(demo_badge_label(&ke(KeyCode::Backspace, none)), Some("[BKSP]"));
assert_eq!(
demo_badge_label(&ke(KeyCode::PageDown, none)),
Some("[PGDN]")
);
assert_eq!(
demo_badge_label(&ke(KeyCode::Backspace, none)),
Some("[BKSP]")
);
assert_eq!(demo_badge_label(&ke(KeyCode::Delete, none)), Some("[DEL]"));
assert_eq!(
demo_badge_label(&ke(KeyCode::Char('o'), KeyModifiers::CONTROL)),
@@ -3493,12 +3444,24 @@ mod tests {
#[test]
fn demo_badge_label_none_for_glyphs_and_excluded_chords() {
// Plain characters render their own glyph — no badge.
assert_eq!(demo_badge_label(&ke(KeyCode::Char('a'), KeyModifiers::NONE)), None);
assert_eq!(demo_badge_label(&ke(KeyCode::Char(' '), KeyModifiers::NONE)), None);
assert_eq!(
demo_badge_label(&ke(KeyCode::Char('a'), KeyModifiers::NONE)),
None
);
assert_eq!(
demo_badge_label(&ke(KeyCode::Char(' '), KeyModifiers::NONE)),
None
);
// Quit and the (Phase C) caption toggle are deliberately excluded.
assert_eq!(demo_badge_label(&ke(KeyCode::Char('c'), KeyModifiers::CONTROL)), None);
assert_eq!(
demo_badge_label(&ke(KeyCode::Char('c'), KeyModifiers::CONTROL)),
None
);
// Ctrl+] decodes to Char('5')+CONTROL — must not badge.
assert_eq!(demo_badge_label(&ke(KeyCode::Char('5'), KeyModifiers::CONTROL)), None);
assert_eq!(
demo_badge_label(&ke(KeyCode::Char('5'), KeyModifiers::CONTROL)),
None
);
}
#[test]
@@ -3606,7 +3569,10 @@ mod tests {
assert!(app.demo_caption_capturing, "still capturing");
assert_eq!(app.demo_caption_buffer, "note");
assert_eq!(app.input, "");
assert_eq!(app.demo_badge, None, "inert keys raise no badge while capturing");
assert_eq!(
app.demo_badge, None,
"inert keys raise no badge while capturing"
);
}
#[test]
@@ -4211,7 +4177,9 @@ mod tests {
type_str(&mut app, "copy sideways");
let actions = submit(&mut app);
assert!(
!actions.iter().any(|a| matches!(a, Action::CopyToClipboard(_))),
!actions
.iter()
.any(|a| matches!(a, Action::CopyToClipboard(_))),
"an unknown target does not copy",
);
let rendered = app
@@ -4399,7 +4367,10 @@ mod tests {
);
// … names the table's columns so the user can see what's needed …
assert!(
out.contains("Name") && out.contains("Age") && out.contains("id") && out.contains("SerNo"),
out.contains("Name")
&& out.contains("Age")
&& out.contains("id")
&& out.contains("SerNo"),
"missing the column-name list in: {out}",
);
// … and shows the column-list override targeting the non-auto columns.
@@ -4423,9 +4394,15 @@ mod tests {
let _ = submit(&mut app);
let out = error_lines(&app);
// The teaching line names the user-supplied columns …
assert!(out.contains("Name") && out.contains("Age"), "missing non-auto column names in: {out}");
assert!(
out.contains("Name") && out.contains("Age"),
"missing non-auto column names in: {out}"
);
// … the auto-generated columns …
assert!(out.contains("id") && out.contains("SerNo"), "missing auto column names in: {out}");
assert!(
out.contains("id") && out.contains("SerNo"),
"missing auto column names in: {out}"
);
// … signals the contract …
assert!(
out.contains("auto-generated"),
@@ -4520,10 +4497,7 @@ mod tests {
let mut app = App::new();
install_customers_schema_two_serials(&mut app);
app.mode = Mode::Advanced;
type_str(
&mut app,
"insert into Customers values (13, 'Oli', 42, 13)",
);
type_str(&mut app, "insert into Customers values (13, 'Oli', 42, 13)");
let actions = submit(&mut app);
assert!(
actions
@@ -4552,7 +4526,9 @@ mod tests {
type_str(&mut app, "insert into Customers values ('Oli', 52, 3)");
let actions = submit(&mut app);
assert!(
!actions.iter().any(|a| matches!(a, Action::ExecuteDsl { .. })),
!actions
.iter()
.any(|a| matches!(a, Action::ExecuteDsl { .. })),
"simple-mode Form B count mismatch must NOT dispatch; got: {actions:?}",
);
}
@@ -4565,7 +4541,9 @@ mod tests {
type_str(&mut app, "insert into Customers values ('Oli')");
let actions = submit(&mut app);
assert!(
!actions.iter().any(|a| matches!(a, Action::ExecuteDsl { .. })),
!actions
.iter()
.any(|a| matches!(a, Action::ExecuteDsl { .. })),
"simple-mode Form B under-supply must NOT dispatch; got: {actions:?}",
);
}
@@ -4580,7 +4558,9 @@ mod tests {
type_str(&mut app, "insert into Customers (Name, Age) values ('Oli')");
let actions = submit(&mut app);
assert!(
!actions.iter().any(|a| matches!(a, Action::ExecuteDsl { .. })),
!actions
.iter()
.any(|a| matches!(a, Action::ExecuteDsl { .. })),
"simple-mode Form A count mismatch must NOT dispatch; got: {actions:?}",
);
}
@@ -4616,9 +4596,7 @@ mod tests {
for c in &tc {
app.schema_cache.columns.push(c.name.clone());
}
app.schema_cache
.table_columns
.insert("T".to_string(), tc);
app.schema_cache.table_columns.insert("T".to_string(), tc);
type_str(&mut app, "insert into T values (1, 2), (3, 4)");
let actions = submit(&mut app);
assert!(
@@ -4649,11 +4627,11 @@ mod tests {
// advanced-mode hint at all, so we look for any line carrying
// the "mode advanced" actionable fragment that the pointer
// always emits.
let has_pointer = app
.output
.iter()
.any(|l| l.text.contains("mode advanced"));
assert!(!has_pointer, "unknown command must not point at advanced mode");
let has_pointer = app.output.iter().any(|l| l.text.contains("mode advanced"));
assert!(
!has_pointer,
"unknown command must not point at advanced mode"
);
}
#[test]
@@ -4702,7 +4680,11 @@ mod tests {
app.mode = mode;
type_str(&mut app, input);
match submit(&mut app).as_slice() {
[Action::ExecuteDsl { submission_mode, .. }] => *submission_mode,
[
Action::ExecuteDsl {
submission_mode, ..
},
] => *submission_mode,
other => panic!("expected one ExecuteDsl; got {other:?}"),
}
};
@@ -4733,7 +4715,9 @@ mod tests {
app.update(AppEvent::DslSucceeded {
command: cmd.clone(),
description: None,
echo: Some(vec!["CREATE TABLE Other (id serial PRIMARY KEY)".to_string()]),
echo: Some(vec![
"CREATE TABLE Other (id serial PRIMARY KEY)".to_string(),
]),
});
let texts: Vec<&str> = app.output.iter().map(|l| l.text.as_str()).collect();
// ADR-0040: no `[ok]` summary; with no preceding `running:` echo
@@ -4792,7 +4776,10 @@ mod tests {
.position(|t| t.contains("Executing SQL:"))
.expect("an echo line");
assert_eq!(echo_idx, 0, "teaching echo leads the output: {texts:?}");
assert!(texts[echo_idx].contains(expected), "echo carries the SQL: {texts:?}");
assert!(
texts[echo_idx].contains(expected),
"echo carries the SQL: {texts:?}"
);
// ADR-0038 §4 polish: every success arm now wires the echo as
// `OutputKind::TeachingEcho` so `ui::render_output_line` fires
// the dim-prefix + advanced-lex custom branch. Pinning this
@@ -4911,7 +4898,9 @@ mod tests {
description: sample_description("T"),
client_side: None,
},
echo: Some(vec!["ALTER TABLE T ALTER COLUMN c SET DATA TYPE text".to_string()]),
echo: Some(vec![
"ALTER TABLE T ALTER COLUMN c SET DATA TYPE text".to_string(),
]),
dont_convert_caveat: false,
});
assert_echo_beneath_ok(&app, "ALTER TABLE T ALTER COLUMN c SET DATA TYPE text");
@@ -5126,9 +5115,7 @@ mod tests {
dont_convert_caveat: false,
});
assert!(
!app.output
.iter()
.any(|l| l.text.contains("--dont-convert")),
!app.output.iter().any(|l| l.text.contains("--dont-convert")),
"no caveat in simple mode (no echo to refer to)",
);
}
@@ -5198,7 +5185,10 @@ mod tests {
);
// Pin the `Executing SQL:` prefix repeats once per statement
// (the plain-rendering shape until the styled-runs polish lands).
let exec_count = texts.iter().filter(|t| t.contains("Executing SQL:")).count();
let exec_count = texts
.iter()
.filter(|t| t.contains("Executing SQL:"))
.count();
assert_eq!(exec_count, 3, "one Executing SQL: per statement: {texts:?}");
}
@@ -5223,19 +5213,13 @@ mod tests {
// (could be the caret line, the parse-error detail
// line, or the usage line). Scan for the friendly
// "unknown mode" anchor phrase.
let anywhere = app
.output
.iter()
.any(|l| l.text.contains("unknown mode"));
let anywhere = app.output.iter().any(|l| l.text.contains("unknown mode"));
assert!(
anywhere,
"expected 'unknown mode' somewhere in output: {:?}",
app.output.iter().map(|l| &l.text).collect::<Vec<_>>(),
);
let any_error = app
.output
.iter()
.any(|l| l.kind == OutputKind::Error);
let any_error = app.output.iter().any(|l| l.kind == OutputKind::Error);
assert!(any_error, "expected at least one Error line");
}
@@ -5325,11 +5309,17 @@ mod tests {
app.schema_cache.tables = vec!["Orders".into(), "Customers".into()];
app.schema_cache.table_columns.insert(
"Orders".into(),
vec![TableColumn::new("id", Type::Serial), TableColumn::new("customer_id", Type::Int)],
vec![
TableColumn::new("id", Type::Serial),
TableColumn::new("customer_id", Type::Int),
],
);
app.schema_cache.table_columns.insert(
"Customers".into(),
vec![TableColumn::new("id", Type::Serial), TableColumn::new("name", Type::Text)],
vec![
TableColumn::new("id", Type::Serial),
TableColumn::new("name", Type::Text),
],
);
for t in app.schema_cache.tables.clone() {
for c in &app.schema_cache.table_columns[&t] {
@@ -5490,10 +5480,7 @@ mod tests {
detail: "SCAN Customers".to_string(),
}],
};
app.update(AppEvent::DslExplainSucceeded {
command: cmd,
plan,
});
app.update(AppEvent::DslExplainSucceeded { command: cmd, plan });
// ADR-0040: no `[ok] explain …` header — the (no-echo here)
// command's success shows via the marker; the plan output
// itself carries the content.
@@ -5549,7 +5536,11 @@ mod tests {
.iter()
.find(|l| l.kind == OutputKind::Echo)
.expect("dispatch pushed an echo");
assert_eq!(echo.status, Some(EchoStatus::Pending), "pending before result");
assert_eq!(
echo.status,
Some(EchoStatus::Pending),
"pending before result"
);
app.update(AppEvent::DslSucceeded {
command: Command::CreateTable {
name: "T".to_string(),
@@ -5639,8 +5630,14 @@ mod tests {
.collect::<Vec<_>>()
.join("\n");
assert!(text.contains("[ok] replay"), "summary present:\n{text}");
assert!(text.contains("import a.zip"), "import skip warning rendered:\n{text}");
assert!(text.contains("nested `replay x`"), "nested-replay skip warning rendered:\n{text}");
assert!(
text.contains("import a.zip"),
"import skip warning rendered:\n{text}"
);
assert!(
text.contains("nested `replay x`"),
"nested-replay skip warning rendered:\n{text}"
);
}
#[test]
@@ -5736,7 +5733,7 @@ mod tests {
#[test]
fn hint_command_parses_to_app_hint() {
use crate::dsl::{parse_command, AppCommand, Command};
use crate::dsl::{AppCommand, Command, parse_command};
assert!(matches!(
parse_command("hint"),
Ok(Command::App(AppCommand::Hint))
@@ -5747,7 +5744,7 @@ mod tests {
#[test]
fn version_command_parses_to_app_version() {
use crate::dsl::{parse_command, AppCommand, Command};
use crate::dsl::{AppCommand, Command, parse_command};
assert!(matches!(
parse_command("version"),
Ok(Command::App(AppCommand::Version))
@@ -5801,7 +5798,10 @@ mod tests {
let mut app = App::new();
type_str(&mut app, "show ");
app.update(key(KeyCode::Tab));
assert!(app.last_completion.is_some(), "precondition: Tab sets the memo");
assert!(
app.last_completion.is_some(),
"precondition: Tab sets the memo"
);
let input = app.input.clone();
f1(&mut app);
assert!(app.last_completion.is_some(), "F1 must not clear the memo");
@@ -5827,8 +5827,14 @@ mod tests {
let input = app.input.clone();
let before = app.output.len();
app.update(ctrl_g());
assert_eq!(app.input, input, "Ctrl-G must not change the buffer (no `g` typed)");
assert!(app.output.len() > before, "Ctrl-G must emit the same hint F1 does");
assert_eq!(
app.input, input,
"Ctrl-G must not change the buffer (no `g` typed)"
);
assert!(
app.output.len() > before,
"Ctrl-G must emit the same hint F1 does"
);
}
#[test]
@@ -5855,7 +5861,11 @@ mod tests {
let before = app.output.len();
app.update(ctrl_g());
assert_eq!(app.input, input, "Ctrl-G must not insert a `g`");
assert_eq!(app.output.len(), before, "Ctrl-G does nothing when demo mode is off");
assert_eq!(
app.output.len(),
before,
"Ctrl-G does nothing when demo mode is off"
);
}
#[test]
@@ -5949,7 +5959,10 @@ mod tests {
#[test]
fn f1_on_add_relationship_renders_the_relationship_block() {
let mut app = App::new();
type_str(&mut app, "add 1:n relationship from Customers.id to Orders.cust ");
type_str(
&mut app,
"add 1:n relationship from Customers.id to Orders.cust ",
);
f1(&mut app);
assert!(
output_contains(&app, "one parent, many children"),
@@ -6142,14 +6155,8 @@ mod tests {
let mut app = App::new();
let cmd = Command::Update {
table: "Customers".to_string(),
assignments: vec![(
"id".to_string(),
crate::dsl::Value::Number("7".to_string()),
)],
filter: crate::dsl::RowFilter::eq(
"name",
crate::dsl::Value::Text("Bob".to_string()),
),
assignments: vec![("id".to_string(), crate::dsl::Value::Number("7".to_string()))],
filter: crate::dsl::RowFilter::eq("name", crate::dsl::Value::Text("Bob".to_string())),
};
let err = crate::db::DbError::Sqlite {
message: "UNIQUE constraint failed: Customers.id".to_string(),
@@ -6709,7 +6716,10 @@ mod tests {
app.update(key(KeyCode::Backspace));
let actions = app.update(key(KeyCode::Enter));
assert_eq!(app.input, "select", "input untouched in navigation mode");
assert!(actions.is_empty(), "Enter does not submit in navigation mode");
assert!(
actions.is_empty(),
"Enter does not submit in navigation mode"
);
}
#[test]
@@ -6720,7 +6730,10 @@ mod tests {
app.update(key(KeyCode::Down));
app.update(key(KeyCode::Down));
assert_eq!(app.tables_scroll, 2);
assert_eq!(app.relationships_scroll, 0, "only the focused panel scrolls");
assert_eq!(
app.relationships_scroll, 0,
"only the focused panel scrolls"
);
app.update(key(KeyCode::Up));
assert_eq!(app.tables_scroll, 1);
// Up saturates at the top.
@@ -6998,7 +7011,8 @@ mod tests {
for round in 0..3 {
app.update(key(KeyCode::Up));
assert_eq!(
app.input, "insert into Thing values (1)",
app.input,
"insert into Thing values (1)",
"Up #{} should recall the newest entry",
round + 1,
);
@@ -7220,8 +7234,7 @@ mod tests {
has_default: false,
}],
);
app.input =
"select * from products where price like 5".to_string();
app.input = "select * from products where price like 5".to_string();
assert_eq!(
app.input_validity_verdict(),
Some(crate::dsl::walker::Severity::Warning),
@@ -7350,8 +7363,10 @@ mod tests {
"directly-deleted count surfaced: {texts:?}",
);
assert!(
texts.iter().any(|t| t.contains("2 row(s) deleted in `Orders`")
&& t.contains("relationship `places`")),
texts
.iter()
.any(|t| t.contains("2 row(s) deleted in `Orders`")
&& t.contains("relationship `places`")),
"per-relationship cascade summary surfaced: {texts:?}",
);
}
@@ -7386,11 +7401,15 @@ mod tests {
});
let texts: Vec<String> = app.output.iter().map(|l| l.text.clone()).collect();
assert!(
texts.iter().any(|t| t.contains("20 row(s) seeded into users")),
texts
.iter()
.any(|t| t.contains("20 row(s) seeded into users")),
"seeded-row count surfaced: {texts:?}",
);
assert!(
texts.iter().any(|t| t.contains("status") && t.contains("generic text")),
texts
.iter()
.any(|t| t.contains("status") && t.contains("generic text")),
"the advisory names the enum-ish column: {texts:?}",
);
}
@@ -7424,8 +7443,9 @@ mod tests {
});
let texts: Vec<String> = app.output.iter().map(|l| l.text.clone()).collect();
assert!(
texts.iter().any(|t| t.contains("4 row(s) seeded into J")
&& t.contains("of 10 requested")),
texts
.iter()
.any(|t| t.contains("4 row(s) seeded into J") && t.contains("of 10 requested")),
"the cap note surfaces requested vs produced: {texts:?}",
);
}
@@ -7464,7 +7484,9 @@ mod tests {
});
let texts: Vec<String> = app.output.iter().map(|l| l.text.clone()).collect();
assert!(
texts.iter().any(|t| t.contains("2 row(s) deleted in `Orders`")),
texts
.iter()
.any(|t| t.contains("2 row(s) deleted in `Orders`")),
"cascade summary still surfaces alongside RETURNING: {texts:?}",
);
assert!(
+37 -37
View File
@@ -30,9 +30,7 @@ use std::path::{Component, Path, PathBuf};
use tracing::{debug, info};
use zip::{CompressionMethod, ZipArchive, ZipWriter, write::SimpleFileOptions};
use crate::project::{
HISTORY_LOG, PLAYGROUND_DB, PROJECT_YAML, naming::today_local,
};
use crate::project::{HISTORY_LOG, PLAYGROUND_DB, PROJECT_YAML, naming::today_local};
/// File names excluded from `export` zips. These are either
/// derived (`playground.db`), per-process (`.lock`),
@@ -118,20 +116,14 @@ impl std::fmt::Display for ArchiveError {
limit = format_args!("{limit:02}"),
))
}
Self::InvalidZip(detail) => f.write_str(&crate::t!(
"archive.invalid_zip",
detail = detail,
)),
Self::NotAProjectArchive => {
f.write_str(&crate::t!("archive.not_a_project_archive"))
Self::InvalidZip(detail) => {
f.write_str(&crate::t!("archive.invalid_zip", detail = detail,))
}
Self::MultipleTopFolders => {
f.write_str(&crate::t!("archive.multiple_top_folders"))
Self::NotAProjectArchive => f.write_str(&crate::t!("archive.not_a_project_archive")),
Self::MultipleTopFolders => f.write_str(&crate::t!("archive.multiple_top_folders")),
Self::UnsafeEntry(entry) => {
f.write_str(&crate::t!("archive.unsafe_entry", entry = entry,))
}
Self::UnsafeEntry(entry) => f.write_str(&crate::t!(
"archive.unsafe_entry",
entry = entry,
)),
}
}
}
@@ -216,13 +208,7 @@ pub fn export_project(
.unix_permissions(0o644);
add_directory_entry(&mut writer, project_name, dst_zip)?;
add_directory_recursive(
&mut writer,
project_path,
project_name,
&options,
dst_zip,
)?;
add_directory_recursive(&mut writer, project_path, project_name, &options, dst_zip)?;
writer.finish().map_err(|e| ArchiveError::Zip {
path: dst_zip.to_path_buf(),
@@ -392,10 +378,7 @@ pub struct ZipInspection {
///
/// Returns the resolved target path and the suffix that was
/// applied (0 if the original name was free, 2..=99 otherwise).
pub fn resolve_import_target(
parent: &Path,
name: &str,
) -> Result<(PathBuf, u32), ArchiveError> {
pub fn resolve_import_target(parent: &Path, name: &str) -> Result<(PathBuf, u32), ArchiveError> {
let direct = parent.join(name);
if !direct.exists() {
return Ok((direct, 0));
@@ -495,10 +478,12 @@ pub fn extract_into(
source,
})?;
let mut buf = Vec::with_capacity(entry.size() as usize);
entry.read_to_end(&mut buf).map_err(|source| ArchiveError::Io {
path: dst_path.clone(),
source,
})?;
entry
.read_to_end(&mut buf)
.map_err(|source| ArchiveError::Io {
path: dst_path.clone(),
source,
})?;
out.write_all(&buf).map_err(|source| ArchiveError::Io {
path: dst_path.clone(),
source,
@@ -523,7 +508,11 @@ mod tests {
fs::write(p.join(PROJECT_YAML), "version: 1\nproject:\n created_at: 2026-01-01T00:00:00Z\ntables: []\nrelationships: []\n").unwrap();
fs::create_dir_all(p.join("data")).unwrap();
fs::write(p.join("data/Customers.csv"), "Name\nAlice\nBob\n").unwrap();
fs::write(p.join(HISTORY_LOG), "T|ok|create table Customers with pk id(serial)\n").unwrap();
fs::write(
p.join(HISTORY_LOG),
"T|ok|create table Customers with pk id(serial)\n",
)
.unwrap();
fs::write(p.join(PLAYGROUND_DB), [0u8; 32]).unwrap();
fs::write(p.join(GITIGNORE), "/playground.db\n").unwrap();
// Stray atomic-write staging file — must be excluded.
@@ -536,7 +525,9 @@ mod tests {
)
.unwrap();
fs::write(
p.join(crate::undo::SNAPSHOTS_DIR).join("0").join(PLAYGROUND_DB),
p.join(crate::undo::SNAPSHOTS_DIR)
.join("0")
.join(PLAYGROUND_DB),
[0u8; 16],
)
.unwrap();
@@ -618,11 +609,15 @@ mod tests {
let zip_path = tmp.path().join("notaproject.zip");
let f = fs::File::create(&zip_path).unwrap();
let mut w = ZipWriter::new(f);
w.start_file("foo/bar.txt", SimpleFileOptions::default()).unwrap();
w.start_file("foo/bar.txt", SimpleFileOptions::default())
.unwrap();
w.write_all(b"hi").unwrap();
w.finish().unwrap();
let err = inspect_zip(&zip_path).expect_err("must refuse");
assert!(matches!(err, ArchiveError::NotAProjectArchive), "got: {err:?}");
assert!(
matches!(err, ArchiveError::NotAProjectArchive),
"got: {err:?}"
);
}
#[test]
@@ -631,13 +626,18 @@ mod tests {
let zip_path = tmp.path().join("multi.zip");
let f = fs::File::create(&zip_path).unwrap();
let mut w = ZipWriter::new(f);
w.start_file("a/project.yaml", SimpleFileOptions::default()).unwrap();
w.start_file("a/project.yaml", SimpleFileOptions::default())
.unwrap();
w.write_all(b"x").unwrap();
w.start_file("b/project.yaml", SimpleFileOptions::default()).unwrap();
w.start_file("b/project.yaml", SimpleFileOptions::default())
.unwrap();
w.write_all(b"x").unwrap();
w.finish().unwrap();
let err = inspect_zip(&zip_path).expect_err("must refuse");
assert!(matches!(err, ArchiveError::MultipleTopFolders), "got: {err:?}");
assert!(
matches!(err, ArchiveError::MultipleTopFolders),
"got: {err:?}"
);
}
#[test]
+44 -24
View File
@@ -96,10 +96,7 @@ pub enum ArgsError {
impl std::fmt::Display for ArgsError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::MissingValue(flag) => f.write_str(&crate::t!(
"cli.missing_value",
flag = flag,
)),
Self::MissingValue(flag) => f.write_str(&crate::t!("cli.missing_value", flag = flag,)),
Self::InvalidValue {
flag,
value,
@@ -110,10 +107,7 @@ impl std::fmt::Display for ArgsError {
value = value,
expected = expected,
)),
Self::Unknown(arg) => f.write_str(&crate::t!(
"cli.unknown_argument",
arg = arg,
)),
Self::Unknown(arg) => f.write_str(&crate::t!("cli.unknown_argument", arg = arg,)),
Self::MultiplePaths { first, second } => f.write_str(&crate::t!(
"cli.multiple_paths",
first = first,
@@ -261,7 +255,11 @@ fn default_theme() -> Theme {
// Standard convention: 0..=6 and 8 are dark backgrounds,
// 7 and 9..=15 are light. ITerm emits 15 for white-ish.
let is_dark = matches!(code, 0..=6 | 8);
return if is_dark { Theme::dark() } else { Theme::light() };
return if is_dark {
Theme::dark()
} else {
Theme::light()
};
}
Theme::default()
}
@@ -314,10 +312,19 @@ mod tests {
#[test]
fn mode_flag_simple_and_advanced() {
assert_eq!(Args::parse(["--mode", "simple"]).unwrap().mode, Some(Mode::Simple));
assert_eq!(Args::parse(["--mode", "advanced"]).unwrap().mode, Some(Mode::Advanced));
assert_eq!(
Args::parse(["--mode", "simple"]).unwrap().mode,
Some(Mode::Simple)
);
assert_eq!(
Args::parse(["--mode", "advanced"]).unwrap().mode,
Some(Mode::Advanced)
);
// Case-insensitive, like the `mode` command.
assert_eq!(Args::parse(["--mode", "ADVANCED"]).unwrap().mode, Some(Mode::Advanced));
assert_eq!(
Args::parse(["--mode", "ADVANCED"]).unwrap().mode,
Some(Mode::Advanced)
);
}
#[test]
@@ -350,7 +357,10 @@ mod tests {
#[test]
fn data_dir_flag_parses() {
let args = Args::parse(["--data-dir", "/tmp/playground-data"]).unwrap();
assert_eq!(args.data_dir.as_deref(), Some(std::path::Path::new("/tmp/playground-data")));
assert_eq!(
args.data_dir.as_deref(),
Some(std::path::Path::new("/tmp/playground-data"))
);
}
#[test]
@@ -370,13 +380,11 @@ mod tests {
#[test]
fn data_dir_and_positional_can_coexist() {
let args = Args::parse([
"--data-dir",
"/tmp/data",
"/home/me/MyProject",
])
.unwrap();
assert_eq!(args.data_dir.as_deref(), Some(std::path::Path::new("/tmp/data")));
let args = Args::parse(["--data-dir", "/tmp/data", "/home/me/MyProject"]).unwrap();
assert_eq!(
args.data_dir.as_deref(),
Some(std::path::Path::new("/tmp/data"))
);
assert_eq!(
args.project_path.as_deref(),
Some(std::path::Path::new("/home/me/MyProject"))
@@ -386,7 +394,10 @@ mod tests {
#[test]
fn two_positional_paths_error() {
let err = Args::parse(["/a", "/b"]).unwrap_err();
assert!(matches!(err, ArgsError::MultiplePaths { .. }), "got: {err:?}");
assert!(
matches!(err, ArgsError::MultiplePaths { .. }),
"got: {err:?}"
);
}
#[test]
@@ -455,7 +466,10 @@ mod tests {
// Absent `--demo` (and absent env var in the test runner),
// demo mode is off — zero footprint for real users.
let args = Args::parse(std::iter::empty::<&str>()).unwrap();
assert!(!args.demo, "demo is off unless --demo or the env var is given");
assert!(
!args.demo,
"demo is off unless --demo or the env var is given"
);
}
#[test]
@@ -484,7 +498,10 @@ mod tests {
}
// Disabling values.
for v in ["", " ", "0", "false", "False", "no", "off", "OFF"] {
assert!(!demo_value_is_truthy(v), "{v:?} should not enable demo mode");
assert!(
!demo_value_is_truthy(v),
"{v:?} should not enable demo mode"
);
}
}
@@ -493,7 +510,10 @@ mod tests {
// Make sure the path-vs-flag distinction is robust:
// unknown flags don't get silently swallowed as paths.
let err = Args::parse(["--bogus", "/some/path"]).unwrap_err();
assert!(matches!(&err, ArgsError::Unknown(s) if s == "--bogus"), "got: {err:?}");
assert!(
matches!(&err, ArgsError::Unknown(s) if s == "--bogus"),
"got: {err:?}"
);
}
// ---- ADR-0054: --version / -V ----
+254 -210
View File
@@ -15,10 +15,10 @@
//! `app.rs`; this module owns the candidate computation.
use crate::dsl::grammar::IdentSource;
use crate::dsl::parser::parse_command_with_schema_in_mode;
use crate::dsl::types::Type;
use crate::dsl::walker::outcome::Expectation;
use crate::dsl::{ParseError, parse_command};
use crate::dsl::parser::parse_command_with_schema_in_mode;
use crate::mode::Mode;
/// Composite literal candidates whose lexed shape is more than
@@ -275,11 +275,7 @@ pub struct Completion {
/// (case-insensitive starts-with), combined, sorted, and
/// deduplicated.
#[must_use]
pub fn candidates_at_cursor(
input: &str,
cursor: usize,
cache: &SchemaCache,
) -> Option<Completion> {
pub fn candidates_at_cursor(input: &str, cursor: usize, cache: &SchemaCache) -> Option<Completion> {
candidates_at_cursor_in_mode(input, cursor, cache, Mode::Advanced)
}
@@ -358,7 +354,11 @@ pub fn candidates_at_cursor_with_in_mode(
let word_boundary = run == 0 || bytes[run - 1].is_ascii_whitespace();
if run < cursor && bytes[run] == b'-' && word_boundary && run < start {
let pre = crate::dsl::walker::completion_probe_in_mode(&input[..run], cache, mode);
if pre.expected.iter().any(|e| matches!(e, Expectation::Flag(_))) {
if pre
.expected
.iter()
.any(|e| matches!(e, Expectation::Flag(_)))
{
start = run;
}
}
@@ -473,22 +473,19 @@ pub fn candidates_at_cursor_with_in_mode(
// walk's `current_table_columns`; fall back to "the union of
// the look-ahead from_scope's bindings' columns" when leading
// produced no in-scope columns. Phase-1 DSL paths unaffected.
let lookahead_union_columns: Vec<TableColumn> =
if probe.current_table_columns.is_none() {
let mut out: Vec<TableColumn> = Vec::new();
for binding in resolution_from_scope {
for col in &binding.columns {
if !out.iter().any(|c| {
c.name.eq_ignore_ascii_case(&col.name)
}) {
out.push(col.clone());
}
let lookahead_union_columns: Vec<TableColumn> = if probe.current_table_columns.is_none() {
let mut out: Vec<TableColumn> = Vec::new();
for binding in resolution_from_scope {
for col in &binding.columns {
if !out.iter().any(|c| c.name.eq_ignore_ascii_case(&col.name)) {
out.push(col.clone());
}
}
out
} else {
Vec::new()
};
}
out
} else {
Vec::new()
};
let lookahead_slice: Option<&[TableColumn]> = if lookahead_union_columns.is_empty() {
None
} else {
@@ -507,30 +504,23 @@ pub fn candidates_at_cursor_with_in_mode(
// column list (the structural error path surfaces the
// unresolved-prefix message).
let prefix_qualifier = peek_back_qualifier(input, start);
let qualified_columns: Option<Vec<String>> = prefix_qualifier
.as_ref()
.map(|q| {
// ADR-0033 §9: `excluded.|` inside an `INSERT … ON
// CONFLICT … DO UPDATE` completes to the target table's
// columns — `excluded` mirrors the would-be-inserted row.
// The target's columns are the INSERT's
// `current_table_columns` (set by the target-table slot).
// The diagnostic pass enforces the strict DO-UPDATE
// byte-range; completion is the softer surface and offers
// the columns whenever the INSERT target is in hand.
if q.eq_ignore_ascii_case("excluded")
&& let Some(cols) = current_table_columns
{
cols.iter().map(|c| c.name.clone()).collect()
} else {
resolve_qualifier_columns_in(
q,
resolution_from_scope,
resolution_cte_bindings,
cache,
)
}
});
let qualified_columns: Option<Vec<String>> = prefix_qualifier.as_ref().map(|q| {
// ADR-0033 §9: `excluded.|` inside an `INSERT … ON
// CONFLICT … DO UPDATE` completes to the target table's
// columns — `excluded` mirrors the would-be-inserted row.
// The target's columns are the INSERT's
// `current_table_columns` (set by the target-table slot).
// The diagnostic pass enforces the strict DO-UPDATE
// byte-range; completion is the softer surface and offers
// the columns whenever the INSERT target is in hand.
if q.eq_ignore_ascii_case("excluded")
&& let Some(cols) = current_table_columns
{
cols.iter().map(|c| c.name.clone()).collect()
} else {
resolve_qualifier_columns_in(q, resolution_from_scope, resolution_cte_bindings, cache)
}
});
let expected = if probe.expected.is_empty() {
expected_at(leading, mode)
@@ -574,8 +564,7 @@ pub fn candidates_at_cursor_with_in_mode(
Some(crate::dsl::grammar::HintMode::ProseOnly(_))
);
if partial_prefix.is_empty()
&& (prose_only_slot
|| (is_value_literal_signature(&expected) && !has_schema_ident))
&& (prose_only_slot || (is_value_literal_signature(&expected) && !has_schema_ident))
{
return None;
}
@@ -646,7 +635,13 @@ pub fn candidates_at_cursor_with_in_mode(
// shortid). The walker surfaces this as
// `Expectation::Ident { source: Types }`.
let type_names: Vec<String> = if expected.iter().any(|e| {
matches!(e, Expectation::Ident { source: IdentSource::Types, .. })
matches!(
e,
Expectation::Ident {
source: IdentSource::Types,
..
}
)
}) {
Type::all()
.iter()
@@ -725,7 +720,13 @@ pub fn candidates_at_cursor_with_in_mode(
// filtered like every other source; empty prefix offers the whole
// set. Tagged `CandidateKind::Function` for its own colour.
let has_sql_expr_slot = expected.iter().any(|e| {
matches!(e, Expectation::Ident { role: "sql_expr_ident", .. })
matches!(
e,
Expectation::Ident {
role: "sql_expr_ident",
..
}
)
});
let mut functions: Vec<String> = if has_sql_expr_slot {
crate::dsl::sql_functions::KNOWN_SQL_FUNCTIONS
@@ -741,9 +742,15 @@ pub fn candidates_at_cursor_with_in_mode(
// curated vocabulary is offered so a learner can discover `email` /
// `product` / … by Tab. Same `Function` kind / `tok_function` colour
// as SQL functions (no new theme colour — ADR-0048 §Grammar).
let has_generator_slot = expected
.iter()
.any(|e| matches!(e, Expectation::Ident { source: IdentSource::Generators, .. }));
let has_generator_slot = expected.iter().any(|e| {
matches!(
e,
Expectation::Ident {
source: IdentSource::Generators,
..
}
)
});
if has_generator_slot {
functions.extend(
crate::seed::KNOWN_GENERATORS
@@ -765,38 +772,36 @@ pub fn candidates_at_cursor_with_in_mode(
// (the `typing_over_diag` path) — keeps the alias from flashing as
// a bogus "unknown column" while typing. Mixed into `identifiers`
// so it sorts/dedups/colours uniformly with column candidates.
let alias_candidates: Vec<String> =
if has_sql_expr_slot && prefix_qualifier.is_none() {
// Once the partial *exactly* matches an in-scope qualifier,
// discoverability is served — the learner has a whole alias
// in hand and now needs the "add `.column`" hint
// (`diagnostic.alias_used_as_column`), not sibling aliases
// that merely share the prefix. Offering them would also let
// the `typing_over_diag` path suppress that very hint. So in
// the exact-match case we emit no alias candidates and let
// the targeted diagnostic surface.
let partial_is_exact_alias = resolution_from_scope.iter().any(|b| {
let q = b.alias.as_deref().unwrap_or(b.table.as_str());
q.eq_ignore_ascii_case(&partial_prefix)
});
if partial_is_exact_alias {
Vec::new()
} else {
let mut out: Vec<String> = Vec::new();
for binding in resolution_from_scope {
let qualifier =
binding.alias.as_deref().unwrap_or(binding.table.as_str());
if matches_prefix(qualifier)
&& !out.iter().any(|q| q.eq_ignore_ascii_case(qualifier))
{
out.push(qualifier.to_string());
}
}
out
}
} else {
let alias_candidates: Vec<String> = if has_sql_expr_slot && prefix_qualifier.is_none() {
// Once the partial *exactly* matches an in-scope qualifier,
// discoverability is served — the learner has a whole alias
// in hand and now needs the "add `.column`" hint
// (`diagnostic.alias_used_as_column`), not sibling aliases
// that merely share the prefix. Offering them would also let
// the `typing_over_diag` path suppress that very hint. So in
// the exact-match case we emit no alias candidates and let
// the targeted diagnostic surface.
let partial_is_exact_alias = resolution_from_scope.iter().any(|b| {
let q = b.alias.as_deref().unwrap_or(b.table.as_str());
q.eq_ignore_ascii_case(&partial_prefix)
});
if partial_is_exact_alias {
Vec::new()
};
} else {
let mut out: Vec<String> = Vec::new();
for binding in resolution_from_scope {
let qualifier = binding.alias.as_deref().unwrap_or(binding.table.as_str());
if matches_prefix(qualifier)
&& !out.iter().any(|q| q.eq_ignore_ascii_case(qualifier))
{
out.push(qualifier.to_string());
}
}
out
}
} else {
Vec::new()
};
// Source 2: schema identifiers — accumulated across every
// matching schema-listable `Ident { source }` expectation.
@@ -811,9 +816,7 @@ pub fn candidates_at_cursor_with_in_mode(
let mut identifiers: Vec<String> = expected
.iter()
.filter_map(|e| match e {
Expectation::Ident { source, .. } if source.completes_from_schema() => {
Some(*source)
}
Expectation::Ident { source, .. } if source.completes_from_schema() => Some(*source),
_ => None,
})
.flat_map(|source| {
@@ -1007,11 +1010,7 @@ fn resolve_qualifier_columns_in(
.iter()
.find(|c| c.name.eq_ignore_ascii_case(&binding.table))
{
return cte
.columns
.iter()
.filter_map(|c| c.name.clone())
.collect();
return cte.columns.iter().filter_map(|c| c.name.clone()).collect();
}
}
// Second: table-name match in the active from_scope.
@@ -1026,11 +1025,7 @@ fn resolve_qualifier_columns_in(
.iter()
.find(|c| c.name.eq_ignore_ascii_case(&binding.table))
{
return cte
.columns
.iter()
.filter_map(|c| c.name.clone())
.collect();
return cte.columns.iter().filter_map(|c| c.name.clone()).collect();
}
}
// Third: direct cte_bindings match (cte_alias.|).
@@ -1038,11 +1033,7 @@ fn resolve_qualifier_columns_in(
.iter()
.find(|c| c.name.eq_ignore_ascii_case(qualifier))
{
return cte
.columns
.iter()
.filter_map(|c| c.name.clone())
.collect();
return cte.columns.iter().filter_map(|c| c.name.clone()).collect();
}
// Fourth: a bare table name from the schema cache — DSL
// paths reach this for `from <Table>.<col>` shapes where
@@ -1287,7 +1278,13 @@ pub fn invalid_ident_at_cursor_in_mode(
// column. So `select Agx` warns at typing time again, while
// `select sum` does not.
let has_sql_expr_slot = expected.iter().any(|e| {
matches!(e, Expectation::Ident { role: "sql_expr_ident", .. })
matches!(
e,
Expectation::Ident {
role: "sql_expr_ident",
..
}
)
});
if has_sql_expr_slot && crate::dsl::sql_functions::is_known_function_prefix(partial) {
return None;
@@ -1318,9 +1315,15 @@ pub fn invalid_ident_at_cursor_in_mode(
// schema-column check below would never see it. A partial that
// prefix-matches a known generator is an in-progress name; anything
// else is an unknown generator → flag it `[ERR]` while typing.
let has_generator_slot = expected
.iter()
.any(|e| matches!(e, Expectation::Ident { source: IdentSource::Generators, .. }));
let has_generator_slot = expected.iter().any(|e| {
matches!(
e,
Expectation::Ident {
source: IdentSource::Generators,
..
}
)
});
if has_generator_slot {
if crate::seed::is_known_generator_prefix(partial) {
return None;
@@ -1335,9 +1338,7 @@ pub fn invalid_ident_at_cursor_in_mode(
let sources: Vec<IdentSource> = expected
.iter()
.filter_map(|e| match e {
Expectation::Ident { source, .. } if source.completes_from_schema() => {
Some(*source)
}
Expectation::Ident { source, .. } if source.completes_from_schema() => Some(*source),
_ => None,
})
.collect();
@@ -1412,13 +1413,15 @@ mod tests {
use pretty_assertions::assert_eq;
fn cands(input: &str, cursor: usize) -> Vec<String> {
candidates_at_cursor(input, cursor, &SchemaCache::default())
.map_or_else(Vec::new, |c| c.candidates.into_iter().map(|c| c.text).collect())
candidates_at_cursor(input, cursor, &SchemaCache::default()).map_or_else(Vec::new, |c| {
c.candidates.into_iter().map(|c| c.text).collect()
})
}
fn cands_with(input: &str, cursor: usize, cache: &SchemaCache) -> Vec<String> {
candidates_at_cursor(input, cursor, cache)
.map_or_else(Vec::new, |c| c.candidates.into_iter().map(|c| c.text).collect())
candidates_at_cursor(input, cursor, cache).map_or_else(Vec::new, |c| {
c.candidates.into_iter().map(|c| c.text).collect()
})
}
/// Simple-mode completion candidates — the DSL surface
@@ -1429,7 +1432,9 @@ mod tests {
/// Advanced mode surfaces the SQL grammar's completions instead.
fn cands_simple(input: &str, cursor: usize) -> Vec<String> {
candidates_at_cursor_in_mode(input, cursor, &SchemaCache::default(), Mode::Simple)
.map_or_else(Vec::new, |c| c.candidates.into_iter().map(|c| c.text).collect())
.map_or_else(Vec::new, |c| {
c.candidates.into_iter().map(|c| c.text).collect()
})
}
fn cand_kinds_with(
@@ -1438,10 +1443,7 @@ mod tests {
cache: &SchemaCache,
) -> Vec<(String, CandidateKind)> {
candidates_at_cursor(input, cursor, cache).map_or_else(Vec::new, |c| {
c.candidates
.into_iter()
.map(|c| (c.text, c.kind))
.collect()
c.candidates.into_iter().map(|c| (c.text, c.kind)).collect()
})
}
@@ -1503,12 +1505,21 @@ mod tests {
// Simple-only (column, relationship, constraint).
let cs = cands("drop ", 5);
for kw in ["table", "index", "column", "relationship", "constraint"] {
assert!(cs.contains(&kw.to_string()), "`drop ` should offer `{kw}`; got {cs:?}");
assert!(
cs.contains(&kw.to_string()),
"`drop ` should offer `{kw}`; got {cs:?}"
);
}
// Both-mode continuations block before the simple-only ones.
let pos = |k: &str| cs.iter().position(|c| c == k).unwrap();
assert!(pos("table") < pos("column"), "Both block precedes Simple block: {cs:?}");
assert!(pos("index") < pos("relationship"), "Both block precedes Simple block: {cs:?}");
assert!(
pos("table") < pos("column"),
"Both block precedes Simple block: {cs:?}"
);
assert!(
pos("index") < pos("relationship"),
"Both block precedes Simple block: {cs:?}"
);
}
#[test]
@@ -1631,8 +1642,14 @@ mod tests {
let c = candidates_at_cursor(input, input.len(), &SchemaCache::default())
.expect("a `-` at a flag position offers candidates");
let texts: Vec<&str> = c.candidates.iter().map(|x| x.text.as_str()).collect();
assert!(texts.contains(&"--create-fk"), "should offer --create-fk: {texts:?}");
assert!(!texts.contains(&"on"), "must NOT offer `on` after a dash: {texts:?}");
assert!(
texts.contains(&"--create-fk"),
"should offer --create-fk: {texts:?}"
);
assert!(
!texts.contains(&"on"),
"must NOT offer `on` after a dash: {texts:?}"
);
assert_eq!(
c.replaced_range,
(input.len() - 1, input.len()),
@@ -1643,13 +1660,9 @@ mod tests {
#[test]
fn double_dash_replaces_both_dashes_on_accept() {
let input = "delete from T --";
let c = candidates_at_cursor_in_mode(
input,
input.len(),
&SchemaCache::default(),
Mode::Simple,
)
.expect("`--` offers the flag");
let c =
candidates_at_cursor_in_mode(input, input.len(), &SchemaCache::default(), Mode::Simple)
.expect("`--` offers the flag");
assert!(c.candidates.iter().any(|x| x.text == "--all-rows"));
assert_eq!(
c.replaced_range,
@@ -1668,9 +1681,7 @@ mod tests {
s.tables.push("T".into());
s.columns.push("x".into());
let input = "show data T where x = -5";
if let Some(c) =
candidates_at_cursor_in_mode(input, input.len(), &s, Mode::Simple)
{
if let Some(c) = candidates_at_cursor_in_mode(input, input.len(), &s, Mode::Simple) {
assert!(
!c.candidates.iter().any(|x| x.text.starts_with("--")),
"no flags at a value position: {:?}",
@@ -1715,8 +1726,8 @@ mod tests {
// App-lifecycle commands now appear alongside DSL
// commands in the entry-keyword set.
for expected in &[
"quit", "help", "rebuild", "save", "new", "load", "export",
"import", "mode", "messages", "undo", "redo", "copy",
"quit", "help", "rebuild", "save", "new", "load", "export", "import", "mode",
"messages", "undo", "redo", "copy",
] {
assert!(
cs.contains(&expected.to_string()),
@@ -1943,7 +1954,10 @@ mod tests {
// opening a sub-shape) becomes a Tab candidate.
let input = "add column to table T";
let cs = cands(input, input.len());
assert!(cs.is_empty(), "trailing-content punct should not surface: {cs:?}");
assert!(
cs.is_empty(),
"trailing-content punct should not surface: {cs:?}"
);
}
#[test]
@@ -1957,10 +1971,7 @@ mod tests {
assert!(cs.contains(&"(".to_string()), "got {cs:?}");
}
fn schema_with_table(
table: &str,
columns: &[(&str, crate::dsl::types::Type)],
) -> SchemaCache {
fn schema_with_table(table: &str, columns: &[(&str, crate::dsl::types::Type)]) -> SchemaCache {
let mut cache = SchemaCache::default();
cache.tables.push(table.to_string());
let cols: Vec<TableColumn> = columns
@@ -2002,8 +2013,14 @@ mod tests {
let cache = two_table_alias_cache();
let input = "select a.id from a o join b z on o.id = z.id group by ";
let cs = cands_with(input, input.len(), &cache);
assert!(cs.contains(&"o".to_string()), "alias `o` must be offered; got {cs:?}");
assert!(cs.contains(&"z".to_string()), "alias `z` must be offered; got {cs:?}");
assert!(
cs.contains(&"o".to_string()),
"alias `o` must be offered; got {cs:?}"
);
assert!(
cs.contains(&"z".to_string()),
"alias `z` must be offered; got {cs:?}"
);
}
#[test]
@@ -2015,8 +2032,14 @@ mod tests {
let cache = two_table_alias_cache();
let input = "select a.id from a aa join b ab on aa.id = ab.id group by a";
let cs = cands_with(input, input.len(), &cache);
assert!(cs.contains(&"aa".to_string()), "alias `aa` must be offered; got {cs:?}");
assert!(cs.contains(&"ab".to_string()), "alias `ab` must be offered; got {cs:?}");
assert!(
cs.contains(&"aa".to_string()),
"alias `aa` must be offered; got {cs:?}"
);
assert!(
cs.contains(&"ab".to_string()),
"alias `ab` must be offered; got {cs:?}"
);
// Exact-alias partial: the alias source steps aside.
let exact = "select aa.id from a aa join b ab on aa.id = ab.id group by aa";
@@ -2046,19 +2069,20 @@ mod tests {
// SchemaCache.columns has columns from many tables, but
// at `update Customers set ` only Customers' columns
// should appear.
let mut cache = schema_with_table(
"Customers",
&[("id", Type::Int), ("Email", Type::Text)],
);
let mut cache = schema_with_table("Customers", &[("id", Type::Int), ("Email", Type::Text)]);
// Pretend the global flat list has columns from a second
// table that aren't in Customers.
cache.columns.push("OrderTotal".to_string());
cache.columns.push("Stock".to_string());
cache
.table_columns
.insert("Orders".to_string(), vec![
TableColumn { name: "OrderTotal".to_string(), user_type: Type::Real, not_null: false, has_default: false },
]);
cache.table_columns.insert(
"Orders".to_string(),
vec![TableColumn {
name: "OrderTotal".to_string(),
user_type: Type::Real,
not_null: false,
has_default: false,
}],
);
cache.tables.push("Orders".to_string());
let cs = cands_with("update Customers set ", 21, &cache);
// Customers's columns should appear:
@@ -2079,10 +2103,7 @@ mod tests {
// *before* ORDER BY (the FROM's JOIN options, WHERE /
// GROUP BY / HAVING, set-ops). Those used to shove the
// columns off-screen.
let cache = schema_with_table(
"Things",
&[("Name", Type::Text), ("Qty", Type::Int)],
);
let cache = schema_with_table("Things", &[("Name", Type::Text), ("Qty", Type::Int)]);
let input = "select Name from Things order by ";
let cs = cands_with(input, input.len(), &cache);
// The columns the user wants are offered:
@@ -2090,8 +2111,19 @@ mod tests {
assert!(cs.contains(&"Qty".to_string()), "got {cs:?}");
// Preceding-clause keywords must not leak in:
for kw in [
"where", "group", "having", "join", "union", "intersect",
"except", "left", "right", "full", "cross", "inner", "as",
"where",
"group",
"having",
"join",
"union",
"intersect",
"except",
"left",
"right",
"full",
"cross",
"inner",
"as",
] {
assert!(
!cs.contains(&kw.to_string()),
@@ -2108,10 +2140,7 @@ mod tests {
// sort item the direction keywords surface as
// continuations (previously discarded at the Repeated
// boundary, so completion offered neither).
let cache = schema_with_table(
"Things",
&[("Name", Type::Text), ("Qty", Type::Int)],
);
let cache = schema_with_table("Things", &[("Name", Type::Text), ("Qty", Type::Int)]);
let input = "select Name from Things order by Name ";
let cs = cands_with(input, input.len(), &cache);
assert!(cs.contains(&"asc".to_string()), "got {cs:?}");
@@ -2123,10 +2152,7 @@ mod tests {
use crate::dsl::types::Type;
// walk_repeated trailing-optional fix: after a complete
// projection item the `as` alias keyword surfaces.
let cache = schema_with_table(
"Things",
&[("Name", Type::Text), ("Qty", Type::Int)],
);
let cache = schema_with_table("Things", &[("Name", Type::Text), ("Qty", Type::Int)]);
let input = "select Name ";
let cs = cands_with(input, input.len(), &cache);
assert!(cs.contains(&"as".to_string()), "got {cs:?}");
@@ -2153,16 +2179,13 @@ mod tests {
// ADR-0022 Amendment 2: at an expression position offering
// both column names and keywords, every column precedes
// every keyword so the names stay visible by default.
let cache = schema_with_table(
"Things",
&[("Name", Type::Text), ("Qty", Type::Int)],
);
let cache = schema_with_table("Things", &[("Name", Type::Text), ("Qty", Type::Int)]);
let input = "select * from Things where ";
let cs = cands_with(input, input.len(), &cache);
let pos = |needle: &str| {
cs.iter().position(|c| c == needle).unwrap_or_else(|| {
panic!("{needle:?} not in candidates: {cs:?}")
})
cs.iter()
.position(|c| c == needle)
.unwrap_or_else(|| panic!("{needle:?} not in candidates: {cs:?}"))
};
// Both columns come before any expression-start keyword.
let last_ident = pos("Name").max(pos("Qty"));
@@ -2176,13 +2199,9 @@ mod tests {
#[test]
fn update_where_offers_only_current_table_columns() {
use crate::dsl::types::Type;
let mut cache = schema_with_table(
"Customers",
&[("id", Type::Int), ("Email", Type::Text)],
);
let mut cache = schema_with_table("Customers", &[("id", Type::Int), ("Email", Type::Text)]);
cache.columns.push("OrderTotal".to_string());
let cs =
cands_with("update Customers set Email='x' where ", 37, &cache);
let cs = cands_with("update Customers set Email='x' where ", 37, &cache);
assert!(cs.contains(&"id".to_string()), "got {cs:?}");
assert!(cs.contains(&"Email".to_string()), "got {cs:?}");
assert!(!cs.contains(&"OrderTotal".to_string()), "got {cs:?}");
@@ -2208,7 +2227,11 @@ mod tests {
use crate::dsl::types::Type;
let cache = schema_with_table(
"Customers",
&[("id", Type::Int), ("Email", Type::Text), ("Name", Type::Text)],
&[
("id", Type::Int),
("Email", Type::Text),
("Name", Type::Text),
],
);
let cs = cands_with("insert into Customers (", 23, &cache);
// The user is at Form A's column-list position. All
@@ -2222,10 +2245,7 @@ mod tests {
#[test]
fn insert_into_open_paren_does_not_offer_unrelated_columns() {
use crate::dsl::types::Type;
let mut cache = schema_with_table(
"Customers",
&[("id", Type::Int), ("Email", Type::Text)],
);
let mut cache = schema_with_table("Customers", &[("id", Type::Int), ("Email", Type::Text)]);
cache.columns.push("OrderTotal".to_string());
let cs = cands_with("insert into Customers (", 23, &cache);
assert!(!cs.contains(&"OrderTotal".to_string()), "got {cs:?}");
@@ -2239,13 +2259,9 @@ mod tests {
// table's columns. `OrderTotal` belongs to no table in
// this cache's `table_columns`, so it must not leak.
use crate::dsl::types::Type;
let mut cache = schema_with_table(
"Customers",
&[("id", Type::Int), ("Email", Type::Text)],
);
let mut cache = schema_with_table("Customers", &[("id", Type::Int), ("Email", Type::Text)]);
cache.columns.push("OrderTotal".to_string());
let cs =
cands_with("drop column from Customers: ", 28, &cache);
let cs = cands_with("drop column from Customers: ", 28, &cache);
assert!(cs.contains(&"Email".to_string()), "got {cs:?}");
assert!(cs.contains(&"id".to_string()), "got {cs:?}");
assert!(
@@ -2271,8 +2287,8 @@ mod tests {
#[test]
fn cursor_mid_keyword_replaces_only_the_partial_prefix() {
let comp = candidates_at_cursor("cre", 3, &SchemaCache::default())
.expect("some completion");
let comp =
candidates_at_cursor("cre", 3, &SchemaCache::default()).expect("some completion");
assert_eq!(comp.replaced_range, (0, 3));
assert_eq!(comp.partial_prefix, "cre");
assert_eq!(comp.candidates.len(), 1);
@@ -2282,8 +2298,8 @@ mod tests {
#[test]
fn cursor_at_word_boundary_has_empty_partial_prefix() {
let comp = candidates_at_cursor("create ", 7, &SchemaCache::default())
.expect("some completion");
let comp =
candidates_at_cursor("create ", 7, &SchemaCache::default()).expect("some completion");
assert_eq!(comp.replaced_range, (7, 7));
assert_eq!(comp.partial_prefix, "");
}
@@ -2517,8 +2533,8 @@ mod tests {
// inside `Name`, and substituting any name there
// produces a complete command. No useful "next after
// name" hint.
let t = typing_name_at_cursor("add column to table T: Name (text)", 27)
.expect("should fire");
let t =
typing_name_at_cursor("add column to table T: Name (text)", 27).expect("should fire");
assert_eq!(t.next_after_name, None);
}
@@ -2534,8 +2550,8 @@ mod tests {
assert!(invalid_ident_at_cursor("show data Cust", 14, &cache).is_none());
// `show data Cust` plus a typo: `show data Custp`. No
// table starts with "Custp" → invalid.
let invalid = invalid_ident_at_cursor("show data Custp", 15, &cache)
.expect("should be invalid");
let invalid =
invalid_ident_at_cursor("show data Custp", 15, &cache).expect("should be invalid");
assert_eq!(invalid.range, (10, 15));
assert_eq!(invalid.found, "Custp");
assert_eq!(invalid.source, IdentSource::Tables);
@@ -2600,7 +2616,11 @@ mod tests {
!cs.iter().any(|c| c == "Existing" || c == "AlsoExisting"),
"NewName slot must not surface schema candidates; got {cs:?}"
);
assert_eq!(cs, vec!["if".to_string()], "only the advanced IF NOT EXISTS keyword");
assert_eq!(
cs,
vec!["if".to_string()],
"only the advanced IF NOT EXISTS keyword"
);
}
fn keyword_cand(text: &str) -> Candidate {
@@ -2791,8 +2811,10 @@ mod tests {
let cands = candidates_at_cursor(input, input.len(), &cache)
.expect("some completion")
.candidates;
let count_entries: Vec<_> =
cands.iter().filter(|c| c.text.eq_ignore_ascii_case("count")).collect();
let count_entries: Vec<_> = cands
.iter()
.filter(|c| c.text.eq_ignore_ascii_case("count"))
.collect();
assert_eq!(
count_entries.len(),
1,
@@ -2805,7 +2827,9 @@ mod tests {
);
// A non-colliding function at the same slot is unaffected.
assert!(
cands.iter().any(|c| c.text == "coalesce" && c.kind == CandidateKind::Function),
cands
.iter()
.any(|c| c.text == "coalesce" && c.kind == CandidateKind::Function),
"non-colliding functions still surface; got {cands:?}",
);
}
@@ -2875,8 +2899,10 @@ mod tests {
let mut s = SchemaCache::default();
s.tables.push("OrderLines".into());
s.columns.push("count".into());
s.table_columns
.insert("OrderLines".into(), vec![TableColumn::new("count", Type::Int)]);
s.table_columns.insert(
"OrderLines".into(),
vec![TableColumn::new("count", Type::Int)],
);
let input = "select sum(ol.count) from OrderLines ol";
let cursor = input.find("ol.count").unwrap() + 2; // right after `ol`
assert!(
@@ -2938,15 +2964,35 @@ mod tests {
s.table_columns.insert(
"a".to_string(),
vec![
TableColumn { name: "id".to_string(), user_type: Type::Int, not_null: false, has_default: false },
TableColumn { name: "name".to_string(), user_type: Type::Text, not_null: false, has_default: false },
TableColumn {
name: "id".to_string(),
user_type: Type::Int,
not_null: false,
has_default: false,
},
TableColumn {
name: "name".to_string(),
user_type: Type::Text,
not_null: false,
has_default: false,
},
],
);
s.table_columns.insert(
"b".to_string(),
vec![
TableColumn { name: "id".to_string(), user_type: Type::Int, not_null: false, has_default: false },
TableColumn { name: "total".to_string(), user_type: Type::Real, not_null: false, has_default: false },
TableColumn {
name: "id".to_string(),
user_type: Type::Int,
not_null: false,
has_default: false,
},
TableColumn {
name: "total".to_string(),
user_type: Type::Real,
not_null: false,
has_default: false,
},
],
);
s
@@ -3191,5 +3237,3 @@ mod tests {
assert!(candidates_at_cursor_with("create ", 7, &cache, empty_ranker).is_none());
}
}
+1140 -796
View File
File diff suppressed because it is too large Load Diff
+10 -11
View File
@@ -549,9 +549,7 @@ pub enum AppCommand {
/// word like `insert` / `create` / `show`, or `types`), the
/// focused detail for that command (or command group sharing
/// the entry word).
Help {
topic: Option<String>,
},
Help { topic: Option<String> },
/// Show a contextual tier-3 hint (H2 / ADR-0053). No argument:
/// when submitted, it expands on the most recent runtime error
/// (the buffer is empty post-submit). The live-input surface is
@@ -580,7 +578,10 @@ pub enum AppCommand {
/// Unpack a zip into a new project and switch to it.
/// `target` overrides the project name (default: taken from
/// the zip).
Import { path: String, target: Option<String> },
Import {
path: String,
target: Option<String>,
},
/// Switch the persistent input mode.
Mode { value: ModeValue },
/// Show or set the messages verbosity.
@@ -791,9 +792,7 @@ impl PartialEq for Operand {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(Self::Column { name: a, .. }, Self::Column { name: b, .. }) => a == b,
(Self::Literal { value: a, .. }, Self::Literal { value: b, .. }) => {
a == b
}
(Self::Literal { value: a, .. }, Self::Literal { value: b, .. }) => a == b,
_ => false,
}
}
@@ -817,7 +816,9 @@ pub enum CompareOp {
/// a single row in the metadata table.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RelationshipSelector {
Named { name: String },
Named {
name: String,
},
Endpoints {
parent_table: String,
parent_column: String,
@@ -1156,9 +1157,7 @@ impl Command {
parent_column,
child_table,
child_column,
} => format!(
"from {parent_table}.{parent_column} to {child_table}.{child_column}"
),
} => format!("from {parent_table}.{parent_column} to {child_table}.{child_column}"),
},
// A constraint command's subject is the dotted
// `<table>.<column>` it acts on (ADR-0029 §2.2).
+41 -30
View File
@@ -9,8 +9,7 @@
use crate::dsl::command::{AppCommand, Command, CopyScope, MessagesValue, ModeValue};
use crate::dsl::grammar::{
CommandNode, HintMode, IdentSource, IdentValidator, Node, ValidationError,
Word,
CommandNode, HintMode, IdentSource, IdentValidator, Node, ValidationError, Word,
};
use crate::dsl::walker::outcome::{MatchedKind, MatchedPath};
@@ -60,19 +59,16 @@ const IMPORT_TARGET_IDENT: Node = Node::Ident {
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
const IMPORT_TARGET: Node = Node::Hinted {
mode: HintMode::ForceProse("hint.ambient_typing_name"),
inner: &IMPORT_TARGET_IDENT,
};
const IMPORT_AS_TARGET: Node = Node::Seq(&[
Node::Word(Word::keyword("as")),
IMPORT_TARGET,
]);
const IMPORT_AS_TARGET: Node = Node::Seq(&[Node::Word(Word::keyword("as")), IMPORT_TARGET]);
const IMPORT_AS_TARGET_OPT: Node = Node::Optional(&IMPORT_AS_TARGET);
const IMPORT_PATH_AND_TARGET: Node = Node::Seq(&[Node::BarePath, IMPORT_AS_TARGET_OPT]);
@@ -101,9 +97,9 @@ const MODE_CHOICES: &[Node] = &[
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
},
];
const MODE_VALUE: Node = Node::Choice(MODE_CHOICES);
@@ -119,9 +115,9 @@ const MESSAGES_CHOICES: &[Node] = &[
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
},
];
const MESSAGES_VALUE: Node = Node::Choice(MESSAGES_CHOICES);
@@ -271,7 +267,8 @@ pub static QUIT: CommandNode = CommandNode {
ast_builder: build_quit,
help_id: Some("app.quit"),
hint_ids: &["quit"],
usage_ids: &["parse.usage.quit"],};
usage_ids: &["parse.usage.quit"],
};
pub static HELP: CommandNode = CommandNode {
entry: Word::keyword("help"),
@@ -279,7 +276,8 @@ pub static HELP: CommandNode = CommandNode {
ast_builder: build_help,
help_id: Some("app.help"),
hint_ids: &["help"],
usage_ids: &["parse.usage.help"],};
usage_ids: &["parse.usage.help"],
};
pub static HINT: CommandNode = CommandNode {
entry: Word::keyword("hint"),
@@ -288,7 +286,8 @@ pub static HINT: CommandNode = CommandNode {
help_id: Some("app.hint"),
// hint_id assigned in Phase C with the tier-3 corpus (ADR-0053).
hint_ids: &["hint"],
usage_ids: &["parse.usage.hint"],};
usage_ids: &["parse.usage.hint"],
};
pub static REBUILD: CommandNode = CommandNode {
entry: Word::keyword("rebuild"),
@@ -296,7 +295,8 @@ pub static REBUILD: CommandNode = CommandNode {
ast_builder: build_rebuild,
help_id: Some("app.rebuild"),
hint_ids: &["rebuild"],
usage_ids: &["parse.usage.rebuild"],};
usage_ids: &["parse.usage.rebuild"],
};
pub static VERSION: CommandNode = CommandNode {
entry: Word::keyword("version"),
@@ -304,7 +304,8 @@ pub static VERSION: CommandNode = CommandNode {
ast_builder: build_version,
help_id: Some("app.version"),
hint_ids: &["version"],
usage_ids: &["parse.usage.version"],};
usage_ids: &["parse.usage.version"],
};
pub static SAVE: CommandNode = CommandNode {
entry: Word::keyword("save"),
@@ -312,7 +313,8 @@ pub static SAVE: CommandNode = CommandNode {
ast_builder: build_save,
help_id: Some("app.save"),
hint_ids: &["save"],
usage_ids: &["parse.usage.save"],};
usage_ids: &["parse.usage.save"],
};
pub static NEW: CommandNode = CommandNode {
entry: Word::keyword("new"),
@@ -320,7 +322,8 @@ pub static NEW: CommandNode = CommandNode {
ast_builder: build_new,
help_id: Some("app.new"),
hint_ids: &["new"],
usage_ids: &["parse.usage.new"],};
usage_ids: &["parse.usage.new"],
};
pub static LOAD: CommandNode = CommandNode {
entry: Word::keyword("load"),
@@ -328,7 +331,8 @@ pub static LOAD: CommandNode = CommandNode {
ast_builder: build_load,
help_id: Some("app.load"),
hint_ids: &["load"],
usage_ids: &["parse.usage.load"],};
usage_ids: &["parse.usage.load"],
};
pub static EXPORT: CommandNode = CommandNode {
entry: Word::keyword("export"),
@@ -336,7 +340,8 @@ pub static EXPORT: CommandNode = CommandNode {
ast_builder: build_export,
help_id: Some("app.export"),
hint_ids: &["export"],
usage_ids: &["parse.usage.export"],};
usage_ids: &["parse.usage.export"],
};
pub static IMPORT: CommandNode = CommandNode {
entry: Word::keyword("import"),
@@ -344,7 +349,8 @@ pub static IMPORT: CommandNode = CommandNode {
ast_builder: build_import,
help_id: Some("app.import"),
hint_ids: &["import"],
usage_ids: &["parse.usage.import"],};
usage_ids: &["parse.usage.import"],
};
pub static MODE: CommandNode = CommandNode {
entry: Word::keyword("mode"),
@@ -352,7 +358,8 @@ pub static MODE: CommandNode = CommandNode {
ast_builder: build_mode,
help_id: Some("app.mode"),
hint_ids: &["mode"],
usage_ids: &["parse.usage.mode"],};
usage_ids: &["parse.usage.mode"],
};
pub static MESSAGES: CommandNode = CommandNode {
entry: Word::keyword("messages"),
@@ -360,7 +367,8 @@ pub static MESSAGES: CommandNode = CommandNode {
ast_builder: build_messages,
help_id: Some("app.messages"),
hint_ids: &["messages"],
usage_ids: &["parse.usage.messages"],};
usage_ids: &["parse.usage.messages"],
};
pub static UNDO: CommandNode = CommandNode {
entry: Word::keyword("undo"),
@@ -368,7 +376,8 @@ pub static UNDO: CommandNode = CommandNode {
ast_builder: build_undo,
help_id: Some("app.undo"),
hint_ids: &["undo"],
usage_ids: &["parse.usage.undo"],};
usage_ids: &["parse.usage.undo"],
};
pub static REDO: CommandNode = CommandNode {
entry: Word::keyword("redo"),
@@ -376,7 +385,8 @@ pub static REDO: CommandNode = CommandNode {
ast_builder: build_redo,
help_id: Some("app.redo"),
hint_ids: &["redo"],
usage_ids: &["parse.usage.redo"],};
usage_ids: &["parse.usage.redo"],
};
pub static COPY: CommandNode = CommandNode {
entry: Word::keyword("copy"),
@@ -384,4 +394,5 @@ pub static COPY: CommandNode = CommandNode {
ast_builder: build_copy,
help_id: Some("app.copy"),
hint_ids: &["copy"],
usage_ids: &["parse.usage.copy"],};
usage_ids: &["parse.usage.copy"],
};
+101 -78
View File
@@ -24,19 +24,17 @@
//! later swap that capture for the same typed slots used here, adding
//! live hints/highlighting.
use crate::dsl::command::{
Command, Expr, RowFilter, SeedOverride, SeedOverrideKind, ShowListKind,
};
use crate::dsl::command::{Command, Expr, RowFilter, SeedOverride, SeedOverrideKind, ShowListKind};
use crate::dsl::grammar::{
CommandNode, IdentSource, Node, NumberValidator, ValidationError, Word, expr,
shared::{
FALLBACK_VALUE_LIST, column_value_list, count_tuple_values,
current_column_value, insert_target_columns,
FALLBACK_VALUE_LIST, column_value_list, count_tuple_values, current_column_value,
insert_target_columns,
},
sql_delete, sql_insert, sql_select, sql_update,
};
use crate::dsl::walker::context::WalkContext;
use crate::dsl::value::Value;
use crate::dsl::walker::context::WalkContext;
use crate::dsl::walker::outcome::{MatchedItem, MatchedKind, MatchedPath};
// =================================================================
@@ -56,10 +54,10 @@ const TABLE_NAME_EXISTING: Node = Node::Ident {
highlight_override: None,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
/// Table-name slot variant that populates
@@ -75,10 +73,10 @@ const TABLE_NAME_INSERT: Node = Node::Ident {
highlight_override: None,
writes_table: true,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
// =================================================================
@@ -95,10 +93,7 @@ const SHOW_DATA_NODES: &[Node] = &[
];
const SHOW_DATA: Node = Node::Seq(SHOW_DATA_NODES);
const SHOW_TABLE_NODES: &[Node] = &[
Node::Word(Word::keyword("table")),
TABLE_NAME_EXISTING,
];
const SHOW_TABLE_NODES: &[Node] = &[Node::Word(Word::keyword("table")), TABLE_NAME_EXISTING];
const SHOW_TABLE: Node = Node::Seq(SHOW_TABLE_NODES);
// `show tables` / `show relationships` / `show indexes` — the
@@ -144,8 +139,7 @@ const SHOW_INDEX_NAME: Node = Node::Ident {
writes_cte_name: false,
writes_projection_alias: false,
};
const SHOW_INDEX_NODES: &[Node] =
&[Node::Word(Word::keyword("index")), SHOW_INDEX_NAME];
const SHOW_INDEX_NODES: &[Node] = &[Node::Word(Word::keyword("index")), SHOW_INDEX_NAME];
const SHOW_INDEX: Node = Node::Seq(SHOW_INDEX_NODES);
const SHOW_CHOICES: &[Node] = &[
@@ -192,9 +186,9 @@ static FORM_A_COLUMN: Node = Node::Ident {
writes_table: false,
writes_column: false,
writes_user_listed_column: true,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
static INSERT_COMMA: Node = Node::Punct(',');
@@ -224,8 +218,7 @@ fn insert_first_paren(ctx: &WalkContext, source: &str, pos: usize) -> Node {
/// or an identifier-shaped token (a column name) returns false.
fn first_paren_item_is_value_literal(source: &str, pos: usize) -> bool {
use crate::dsl::walker::lex_helpers::{
consume_ident, consume_number_literal, consume_string_literal,
skip_whitespace,
consume_ident, consume_number_literal, consume_string_literal, skip_whitespace,
};
let p = skip_whitespace(source, pos);
if p >= source.len() {
@@ -281,7 +274,11 @@ fn dsl_insert_value_list(ctx: &WalkContext, source: &str, pos: usize) -> Node {
return FALLBACK_VALUE_LIST;
};
let (count, closed) = count_tuple_values(source, pos);
let arity_ok = if closed { count == cols.len() } else { count <= cols.len() };
let arity_ok = if closed {
count == cols.len()
} else {
count <= cols.len()
};
if arity_ok {
Node::DynamicSubgrammar(column_value_list)
} else {
@@ -320,8 +317,7 @@ const INSERT_VALUES_KEYWORD_FIRST_NODES: &[Node] = &[
];
const INSERT_VALUES_KEYWORD_FIRST: Node = Node::Seq(INSERT_VALUES_KEYWORD_FIRST_NODES);
const INSERT_AFTER_TABLE_CHOICES: &[Node] =
&[INSERT_VALUES_KEYWORD_FIRST, INSERT_PAREN_FIRST];
const INSERT_AFTER_TABLE_CHOICES: &[Node] = &[INSERT_VALUES_KEYWORD_FIRST, INSERT_PAREN_FIRST];
const INSERT_AFTER_TABLE: Node = Node::Choice(INSERT_AFTER_TABLE_CHOICES);
const INSERT_NODES: &[Node] = &[
@@ -349,10 +345,10 @@ const TABLE_NAME_WRITES: Node = Node::Ident {
highlight_override: None,
writes_table: true,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
/// Column-name slot in `set col = …` — resolves the column's
@@ -366,9 +362,9 @@ const SET_COLUMN: Node = Node::Ident {
writes_table: false,
writes_column: true,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
/// Value slot resolved at walk time from
@@ -376,11 +372,7 @@ writes_projection_alias: false,
/// value-literal choice when no current_column is bound.
const PER_COLUMN_VALUE: Node = Node::DynamicSubgrammar(current_column_value);
const UPDATE_ASSIGNMENT_NODES: &[Node] = &[
SET_COLUMN,
Node::Punct('='),
PER_COLUMN_VALUE,
];
const UPDATE_ASSIGNMENT_NODES: &[Node] = &[SET_COLUMN, Node::Punct('='), PER_COLUMN_VALUE];
const UPDATE_ASSIGNMENT: Node = Node::Seq(UPDATE_ASSIGNMENT_NODES);
const UPDATE_ASSIGNMENTS: Node = Node::Repeated {
inner: &UPDATE_ASSIGNMENT,
@@ -568,8 +560,7 @@ const SEED_OVERRIDES: Node = Node::Repeated {
separator: Some(&Node::Punct(',')),
min: 1,
};
const SEED_SET_CLAUSE_NODES: &[Node] =
&[Node::Word(Word::keyword("set")), SEED_OVERRIDES];
const SEED_SET_CLAUSE_NODES: &[Node] = &[Node::Word(Word::keyword("set")), SEED_OVERRIDES];
const SEED_SET_CLAUSE: Node = Node::Seq(SEED_SET_CLAUSE_NODES);
const SEED_NODES: &[Node] = &[
@@ -980,7 +971,10 @@ fn parse_seed_override_tail(
MatchedKind::Word("in") => {
*i += 1; // `in`
// `(`
if matches!(region.get(*i).map(|t| &t.kind), Some(MatchedKind::Punct('('))) {
if matches!(
region.get(*i).map(|t| &t.kind),
Some(MatchedKind::Punct('('))
) {
*i += 1;
}
let mut values = Vec::new();
@@ -1001,7 +995,10 @@ fn parse_seed_override_tail(
MatchedKind::Word("between") => {
*i += 1; // `between`
let low = seed_take_value(region, i, column)?;
if matches!(region.get(*i).map(|t| &t.kind), Some(MatchedKind::Word("and"))) {
if matches!(
region.get(*i).map(|t| &t.kind),
Some(MatchedKind::Word("and"))
) {
*i += 1;
}
let high = seed_take_value(region, i, column)?;
@@ -1011,7 +1008,15 @@ fn parse_seed_override_tail(
*i += 1; // `as`
let gen_item = region
.get(*i)
.filter(|t| matches!(t.kind, MatchedKind::Ident { role: "seed_generator", .. }))
.filter(|t| {
matches!(
t.kind,
MatchedKind::Ident {
role: "seed_generator",
..
}
)
})
.ok_or_else(|| seed_set_error(column))?;
*i += 1;
Ok(SeedOverrideKind::Generator(gen_item.text.clone()))
@@ -1085,7 +1090,15 @@ fn build_insert(path: &MatchedPath, _source: &str) -> Result<Command, Validation
let table_idx = path
.items
.iter()
.position(|i| matches!(&i.kind, MatchedKind::Ident { role: "table_name", .. }))
.position(|i| {
matches!(
&i.kind,
MatchedKind::Ident {
role: "table_name",
..
}
)
})
.ok_or_else(|| ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", "missing table".to_string())],
@@ -1141,7 +1154,10 @@ fn build_insert(path: &MatchedPath, _source: &str) -> Result<Command, Validation
if columns.is_empty() {
return Err(ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", "expected column names in `insert into T (…)`".to_string())],
args: vec![(
"detail",
"expected column names in `insert into T (…)`".to_string(),
)],
});
}
// Find the `values` keyword and the next `(` — the values
@@ -1247,9 +1263,7 @@ fn build_update(path: &MatchedPath, _source: &str) -> Result<Command, Validation
})
}
fn collect_assignments(
path: &MatchedPath,
) -> Result<Vec<(String, Value)>, ValidationError> {
fn collect_assignments(path: &MatchedPath) -> Result<Vec<(String, Value)>, ValidationError> {
let mut out = Vec::new();
let mut iter = path.items.iter();
while let Some(item) = iter.next() {
@@ -1495,9 +1509,7 @@ fn build_sql_insert(path: &MatchedPath, source: &str) -> Result<Command, Validat
let row_source = path
.items
.iter()
.find(|item| {
matches!(item.kind, MatchedKind::Word("values" | "select" | "with"))
})
.find(|item| matches!(item.kind, MatchedKind::Word("values" | "select" | "with")))
.map(|item| {
let end = tail_start.unwrap_or(source.len());
source[item.span.0..end]
@@ -1805,7 +1817,8 @@ pub static SHOW: CommandNode = CommandNode {
"parse.usage.show_indexes",
"parse.usage.show_relationship",
"parse.usage.show_index",
],};
],
};
pub static SEED: CommandNode = CommandNode {
entry: Word::keyword("seed"),
@@ -1823,7 +1836,8 @@ pub static INSERT: CommandNode = CommandNode {
help_id: Some("data.insert"),
// ADR-0053 Phase-B exemplar.
hint_ids: &["insert"],
usage_ids: &["parse.usage.insert"],};
usage_ids: &["parse.usage.insert"],
};
pub static UPDATE: CommandNode = CommandNode {
entry: Word::keyword("update"),
@@ -1831,7 +1845,8 @@ pub static UPDATE: CommandNode = CommandNode {
ast_builder: build_update,
help_id: Some("data.update"),
hint_ids: &["update"],
usage_ids: &["parse.usage.update"],};
usage_ids: &["parse.usage.update"],
};
pub static DELETE: CommandNode = CommandNode {
entry: Word::keyword("delete"),
@@ -1839,7 +1854,8 @@ pub static DELETE: CommandNode = CommandNode {
ast_builder: build_delete,
help_id: Some("data.delete"),
hint_ids: &["delete"],
usage_ids: &["parse.usage.delete"],};
usage_ids: &["parse.usage.delete"],
};
pub static REPLAY: CommandNode = CommandNode {
entry: Word::keyword("replay"),
@@ -1847,7 +1863,8 @@ pub static REPLAY: CommandNode = CommandNode {
ast_builder: build_replay,
help_id: Some("data.replay"),
hint_ids: &["replay"],
usage_ids: &["parse.usage.replay"],};
usage_ids: &["parse.usage.replay"],
};
pub static EXPLAIN: CommandNode = CommandNode {
entry: Word::keyword("explain"),
@@ -1855,7 +1872,8 @@ pub static EXPLAIN: CommandNode = CommandNode {
ast_builder: build_explain,
help_id: Some("data.explain"),
hint_ids: &["explain"],
usage_ids: &["parse.usage.explain"],};
usage_ids: &["parse.usage.explain"],
};
/// `explain` over advanced-mode SQL (ADR-0039).
///
@@ -1875,7 +1893,8 @@ pub static EXPLAIN_SQL: CommandNode = CommandNode {
// precedent; otherwise `note_help` would print `explain` twice.
help_id: None,
hint_ids: &["explain_sql"],
usage_ids: &[],};
usage_ids: &[],
};
/// SQL `SELECT` (ADR-0030 §6, ADR-0031, ADR-0032).
///
@@ -1891,7 +1910,8 @@ pub static SELECT: CommandNode = CommandNode {
ast_builder: build_select,
help_id: None,
hint_ids: &["select"],
usage_ids: &["parse.usage.select"],};
usage_ids: &["parse.usage.select"],
};
/// `WITH …` top-level statement (ADR-0032 §4 / sub-phase 2c).
///
@@ -1906,7 +1926,8 @@ pub static WITH: CommandNode = CommandNode {
ast_builder: build_select,
help_id: None,
hint_ids: &["with"],
usage_ids: &["parse.usage.with"],};
usage_ids: &["parse.usage.with"],
};
/// SQL `INSERT` — the `Advanced`-category node of the shared
/// `insert` entry word (ADR-0033 §2, Amendment 1, sub-phase 3j).
@@ -1993,7 +2014,11 @@ mod explain_tests {
#[test]
fn explain_show_data_carries_where_and_limit_through() {
match explain_inner("explain show data Customers where id = 1 limit 5") {
Command::ShowData { name, filter, limit } => {
Command::ShowData {
name,
filter,
limit,
} => {
assert_eq!(name, "Customers");
assert!(filter.is_some(), "where clause should survive");
assert_eq!(limit, Some(5));
@@ -2052,9 +2077,7 @@ mod explain_tests {
/// Advanced-mode counterpart of `explain_inner`.
fn explain_inner_adv(input: &str) -> Command {
match parse_command_in_mode(input, Mode::Advanced)
.expect("advanced explain should parse")
{
match parse_command_in_mode(input, Mode::Advanced).expect("advanced explain should parse") {
Command::Explain { query } => *query,
other => panic!("expected Command::Explain, got {other:?}"),
}
@@ -2085,7 +2108,9 @@ mod explain_tests {
#[test]
fn explain_sql_insert_wraps_a_sql_insert() {
match explain_inner_adv("explain insert into Customers values (1, 'Bo')") {
Command::SqlInsert { sql, target_table, .. } => {
Command::SqlInsert {
sql, target_table, ..
} => {
assert_eq!(target_table, "Customers");
assert_eq!(sql, "insert into Customers values (1, 'Bo')");
}
@@ -2096,7 +2121,9 @@ mod explain_tests {
#[test]
fn explain_sql_update_wraps_a_sql_update_with_clean_sql() {
match explain_inner_adv("explain update Customers set Name = 'Bo' where id = 1") {
Command::SqlUpdate { sql, target_table, .. } => {
Command::SqlUpdate {
sql, target_table, ..
} => {
assert_eq!(target_table, "Customers");
assert_eq!(sql, "update Customers set Name = 'Bo' where id = 1");
}
@@ -2107,7 +2134,9 @@ mod explain_tests {
#[test]
fn explain_sql_delete_wraps_a_sql_delete() {
match explain_inner_adv("explain delete from Customers where id = 1") {
Command::SqlDelete { sql, target_table, .. } => {
Command::SqlDelete {
sql, target_table, ..
} => {
assert_eq!(target_table, "Customers");
assert_eq!(sql, "delete from Customers where id = 1");
}
@@ -2148,11 +2177,7 @@ mod explain_tests {
fn explain_does_not_cover_ddl() {
// EXPLAIN QUERY PLAN applies to DML/queries only (ADR-0039
// out of scope); there is no SQL DDL branch under explain.
assert!(parse_command_in_mode(
"explain create table T (id int)",
Mode::Advanced,
)
.is_err());
assert!(parse_command_in_mode("explain create table T (id int)", Mode::Advanced,).is_err());
}
#[test]
@@ -2165,9 +2190,8 @@ mod explain_tests {
use crate::completion::candidates_at_cursor_in_mode;
let schema = crate::completion::SchemaCache::default();
let input = "explain ";
let completion =
candidates_at_cursor_in_mode(input, input.len(), &schema, Mode::Advanced)
.expect("explain offers candidates");
let completion = candidates_at_cursor_in_mode(input, input.len(), &schema, Mode::Advanced)
.expect("explain offers candidates");
let names: Vec<&str> = completion
.candidates
.iter()
@@ -2178,4 +2202,3 @@ mod explain_tests {
}
}
}
+246 -166
View File
@@ -16,11 +16,11 @@ use crate::dsl::command::{
AlterTableAction, ChangeColumnMode, ColumnSpec, Command, Constraint, ConstraintKind, Expr,
IndexSelector, RelationshipSelector, SqlForeignKey, TableConstraint,
};
use crate::dsl::value::Value;
use crate::dsl::grammar::{
CommandNode, HighlightClass, HintMode, IdentSource, Node, ValidationError, Word,
shared::{REFERENTIAL_CLAUSES, TYPE_SLOT, TYPE_VALIDATOR},
};
use crate::dsl::value::Value;
/// `HintMode` annotation shared by every `NewName` ident slot:
/// the user is inventing a name, so the hint panel forces the
@@ -39,12 +39,12 @@ const TABLE_NAME_NEW_IDENT: Node = Node::Ident {
role: "table_name",
validator: None,
highlight_override: None,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
const TABLE_NAME_NEW: Node = Node::Hinted {
mode: NEW_NAME_HINT,
@@ -63,12 +63,12 @@ const TABLE_NAME_EXISTING: Node = Node::Ident {
role: "table_name",
validator: None,
highlight_override: None,
writes_table: true,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table: true,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
const COLUMN_NAME: Node = Node::Ident {
@@ -76,12 +76,12 @@ const COLUMN_NAME: Node = Node::Ident {
role: "column_name",
validator: None,
highlight_override: None,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
const COLUMN_NAME_NEW_IDENT: Node = Node::Ident {
@@ -89,12 +89,12 @@ const COLUMN_NAME_NEW_IDENT: Node = Node::Ident {
role: "column_name",
validator: None,
highlight_override: None,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
const COLUMN_NAME_NEW: Node = Node::Hinted {
mode: NEW_NAME_HINT,
@@ -106,12 +106,12 @@ const RELATIONSHIP_NAME: Node = Node::Ident {
role: "relationship_name",
validator: None,
highlight_override: None,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
const RELATIONSHIP_NAME_NEW_IDENT: Node = Node::Ident {
@@ -119,12 +119,12 @@ const RELATIONSHIP_NAME_NEW_IDENT: Node = Node::Ident {
role: "relationship_name",
validator: None,
highlight_override: None,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
const RELATIONSHIP_NAME_NEW: Node = Node::Hinted {
mode: NEW_NAME_HINT,
@@ -139,9 +139,9 @@ const INDEX_NAME_EXISTING: Node = Node::Ident {
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
const INDEX_NAME_NEW_IDENT: Node = Node::Ident {
@@ -152,9 +152,9 @@ const INDEX_NAME_NEW_IDENT: Node = Node::Ident {
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
const INDEX_NAME_NEW: Node = Node::Hinted {
mode: NEW_NAME_HINT,
@@ -181,10 +181,7 @@ const TABLE_OPT: Node = Node::Optional(&Node::Word(Word::keyword("table")));
// drop_table — `drop table <T>`
// =================================================================
const DROP_TABLE_NODES: &[Node] = &[
Node::Word(Word::keyword("table")),
TABLE_NAME_EXISTING,
];
const DROP_TABLE_NODES: &[Node] = &[Node::Word(Word::keyword("table")), TABLE_NAME_EXISTING];
const DROP_TABLE: Node = Node::Seq(DROP_TABLE_NODES);
// Advanced-mode SQL `DROP TABLE [IF EXISTS] <name> [;]` (ADR-0035 §4,
@@ -192,8 +189,10 @@ const DROP_TABLE: Node = Node::Seq(DROP_TABLE_NODES);
// plus the optional `IF EXISTS` no-op-with-note. The leading concrete
// `table` keyword (not the Optional) keeps the element/dispatch
// matching honest.
static SQL_DROP_IF_EXISTS_NODES: &[Node] =
&[Node::Word(Word::keyword("if")), Node::Word(Word::keyword("exists"))];
static SQL_DROP_IF_EXISTS_NODES: &[Node] = &[
Node::Word(Word::keyword("if")),
Node::Word(Word::keyword("exists")),
];
const SQL_DROP_IF_EXISTS_OPT: Node = Node::Optional(&Node::Seq(SQL_DROP_IF_EXISTS_NODES));
static SQL_DROP_TABLE_SHAPE_NODES: &[Node] = &[
Node::Word(Word::keyword("table")),
@@ -257,9 +256,9 @@ const DR_PARENT_NODES: &[Node] = &[
writes_table: true,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
},
Node::Punct('.'),
Node::Ident {
@@ -270,9 +269,9 @@ const DR_PARENT_NODES: &[Node] = &[
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
},
];
const DR_PARENT: Node = Node::Seq(DR_PARENT_NODES);
@@ -286,9 +285,9 @@ const DR_CHILD_NODES: &[Node] = &[
writes_table: true,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
},
Node::Punct('.'),
Node::Ident {
@@ -299,9 +298,9 @@ const DR_CHILD_NODES: &[Node] = &[
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
},
];
const DR_CHILD: Node = Node::Seq(DR_CHILD_NODES);
@@ -317,10 +316,7 @@ const DR_ENDPOINTS: Node = Node::Seq(DR_ENDPOINTS_NODES);
const DR_SELECTOR_CHOICES: &[Node] = &[DR_ENDPOINTS, RELATIONSHIP_NAME];
const DR_SELECTOR: Node = Node::Choice(DR_SELECTOR_CHOICES);
const DROP_RELATIONSHIP_NODES: &[Node] = &[
Node::Word(Word::keyword("relationship")),
DR_SELECTOR,
];
const DROP_RELATIONSHIP_NODES: &[Node] = &[Node::Word(Word::keyword("relationship")), DR_SELECTOR];
const DROP_RELATIONSHIP: Node = Node::Seq(DROP_RELATIONSHIP_NODES);
// =================================================================
@@ -341,18 +337,20 @@ const DI_POSITIONAL: Node = Node::Seq(DI_POSITIONAL_NODES);
const DI_SELECTOR_CHOICES: &[Node] = &[DI_POSITIONAL, INDEX_NAME_EXISTING];
const DI_SELECTOR: Node = Node::Choice(DI_SELECTOR_CHOICES);
const DROP_INDEX_NODES: &[Node] = &[
Node::Word(Word::keyword("index")),
DI_SELECTOR,
];
const DROP_INDEX_NODES: &[Node] = &[Node::Word(Word::keyword("index")), DI_SELECTOR];
const DROP_INDEX: Node = Node::Seq(DROP_INDEX_NODES);
// =================================================================
// drop entry — `drop (table|column|relationship|index) ...`
// =================================================================
const DROP_CHOICES: &[Node] =
&[DROP_COLUMN, DROP_RELATIONSHIP, DROP_TABLE, DROP_INDEX, DROP_CONSTRAINT];
const DROP_CHOICES: &[Node] = &[
DROP_COLUMN,
DROP_RELATIONSHIP,
DROP_TABLE,
DROP_INDEX,
DROP_CONSTRAINT,
];
const DROP_SHAPE: Node = Node::Choice(DROP_CHOICES);
// =================================================================
@@ -450,8 +448,7 @@ const AR_CHILD_COL_LIST: Node = Node::Repeated {
separator: Some(&Node::Punct(',')),
min: 1,
};
const AR_CHILD_COLS_PAREN_NODES: &[Node] =
&[Node::Punct('('), AR_CHILD_COL_LIST, Node::Punct(')')];
const AR_CHILD_COLS_PAREN_NODES: &[Node] = &[Node::Punct('('), AR_CHILD_COL_LIST, Node::Punct(')')];
const AR_CHILD_COLS_PAREN: Node = Node::Seq(AR_CHILD_COLS_PAREN_NODES);
const AR_CHILD_COLS_CHOICES: &[Node] = &[AR_CHILD_COLS_PAREN, AR_CHILD_COL];
const AR_CHILD_COLS: Node = Node::Choice(AR_CHILD_COLS_CHOICES);
@@ -474,10 +471,7 @@ const AR_CHILD_NODES: &[Node] = &[
];
const AR_CHILD: Node = Node::Seq(AR_CHILD_NODES);
const AR_AS_NAME_NODES: &[Node] = &[
Node::Word(Word::keyword("as")),
RELATIONSHIP_NAME_NEW,
];
const AR_AS_NAME_NODES: &[Node] = &[Node::Word(Word::keyword("as")), RELATIONSHIP_NAME_NEW];
const AR_AS_NAME_OPT: Node = Node::Optional(&Node::Seq(AR_AS_NAME_NODES));
const AR_CREATE_FK_OPT: Node = Node::Optional(&Node::Flag("create-fk"));
@@ -501,10 +495,7 @@ const ADD_RELATIONSHIP: Node = Node::Seq(ADD_RELATIONSHIP_NODES);
// add_index — `add index [as <name>] on <T> (<col>, …)`
// =================================================================
const AI_AS_NAME_NODES: &[Node] = &[
Node::Word(Word::keyword("as")),
INDEX_NAME_NEW,
];
const AI_AS_NAME_NODES: &[Node] = &[Node::Word(Word::keyword("as")), INDEX_NAME_NEW];
const AI_AS_NAME_OPT: Node = Node::Optional(&Node::Seq(AI_AS_NAME_NODES));
const ADD_INDEX_NODES: &[Node] = &[
@@ -537,9 +528,9 @@ const NEW_COLUMN_NAME_IDENT: Node = Node::Ident {
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
const NEW_COLUMN_NAME: Node = Node::Hinted {
mode: NEW_NAME_HINT,
@@ -563,10 +554,7 @@ const RENAME_COLUMN: Node = Node::Seq(RENAME_COLUMN_NODES);
// ( <type> ) [--force-conversion | --dont-convert]`
// =================================================================
const CHANGE_FLAG_CHOICES: &[Node] = &[
Node::Flag("force-conversion"),
Node::Flag("dont-convert"),
];
const CHANGE_FLAG_CHOICES: &[Node] = &[Node::Flag("force-conversion"), Node::Flag("dont-convert")];
const CHANGE_FLAG_OPT: Node = Node::Repeated {
inner: &Node::Choice(CHANGE_FLAG_CHOICES),
separator: None,
@@ -732,8 +720,7 @@ fn build_add(path: &MatchedPath, _source: &str) -> Result<Command, ValidationErr
message_key: "parse.error_wrapper",
args: vec![("detail", "unknown type".to_string())],
})?;
let (not_null, unique, default, check) =
collect_column_constraints(path)?;
let (not_null, unique, default, check) = collect_column_constraints(path)?;
Ok(Command::AddColumn {
table: require_ident(path, "table_name")?,
column: require_ident(path, "column_name")?,
@@ -949,7 +936,10 @@ fn build_drop_constraint(path: &MatchedPath, _source: &str) -> Result<Command, V
} else {
return Err(ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", "drop constraint needs a constraint kind".to_string())],
args: vec![(
"detail",
"drop constraint needs a constraint kind".to_string(),
)],
});
};
Ok(Command::DropConstraint {
@@ -981,7 +971,8 @@ pub static DROP: CommandNode = CommandNode {
"parse.usage.drop_relationship",
"parse.usage.drop_index",
"parse.usage.drop_constraint",
],};
],
};
pub static ADD: CommandNode = CommandNode {
entry: Word::keyword("add"),
@@ -1003,7 +994,8 @@ pub static ADD: CommandNode = CommandNode {
"parse.usage.add_relationship",
"parse.usage.add_index",
"parse.usage.add_constraint",
],};
],
};
pub static RENAME: CommandNode = CommandNode {
entry: Word::keyword("rename"),
@@ -1011,7 +1003,8 @@ pub static RENAME: CommandNode = CommandNode {
ast_builder: build_rename_column,
help_id: Some("ddl.rename"),
hint_ids: &["rename_column"],
usage_ids: &["parse.usage.rename_column"],};
usage_ids: &["parse.usage.rename_column"],
};
pub static CHANGE: CommandNode = CommandNode {
entry: Word::keyword("change"),
@@ -1019,7 +1012,8 @@ pub static CHANGE: CommandNode = CommandNode {
ast_builder: build_change_column,
help_id: Some("ddl.change"),
hint_ids: &["change_column"],
usage_ids: &["parse.usage.change_column"],};
usage_ids: &["parse.usage.change_column"],
};
// =================================================================
// create_table — `create table <Name> [with pk [<col>(<type>)[, ...]]]`
@@ -1034,9 +1028,9 @@ const COL_NAME_IDENT: Node = Node::Ident {
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
const COL_NAME: Node = Node::Hinted {
mode: NEW_NAME_HINT,
@@ -1074,8 +1068,12 @@ const CHECK_CONSTRAINT_NODES: &[Node] = &[
];
const CHECK_CONSTRAINT: Node = Node::Seq(CHECK_CONSTRAINT_NODES);
const COLUMN_CONSTRAINT_CHOICES: &[Node] =
&[NOT_NULL_CONSTRAINT, UNIQUE_CONSTRAINT, DEFAULT_CONSTRAINT, CHECK_CONSTRAINT];
const COLUMN_CONSTRAINT_CHOICES: &[Node] = &[
NOT_NULL_CONSTRAINT,
UNIQUE_CONSTRAINT,
DEFAULT_CONSTRAINT,
CHECK_CONSTRAINT,
];
const COLUMN_CONSTRAINT: Node = Node::Choice(COLUMN_CONSTRAINT_CHOICES);
/// Zero-or-more constraints — the suffix after a column's
@@ -1114,8 +1112,7 @@ const DROP_CONSTRAINT_KIND: Node = Node::Choice(DROP_CONSTRAINT_KIND_CHOICES);
// `writes_table: true` on the table ident (via `TABLE_NAME_
// EXISTING`) narrows the `.<column>` slot's completion
// candidates to that table's columns.
const CONSTRAINT_TARGET_NODES: &[Node] =
&[TABLE_NAME_EXISTING, Node::Punct('.'), COLUMN_NAME];
const CONSTRAINT_TARGET_NODES: &[Node] = &[TABLE_NAME_EXISTING, Node::Punct('.'), COLUMN_NAME];
const CONSTRAINT_TARGET: Node = Node::Seq(CONSTRAINT_TARGET_NODES);
const ADD_CONSTRAINT_NODES: &[Node] = &[
@@ -1145,9 +1142,9 @@ const COL_SPEC_NODES: &[Node] = &[
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
},
Node::Punct(')'),
COLUMN_CONSTRAINT_SUFFIX,
@@ -1275,10 +1272,14 @@ fn build_create_table(path: &MatchedPath, _source: &str) -> Result<Command, Vali
let mut items = path.items.iter().peekable();
while let Some(item) = items.next() {
match &item.kind {
MatchedKind::Ident { role: "col_name", .. } => {
MatchedKind::Ident {
role: "col_name", ..
} => {
pending_name = Some(item.text.clone());
}
MatchedKind::Ident { role: "col_type", .. } => {
MatchedKind::Ident {
role: "col_type", ..
} => {
let ty = item.text.parse::<Type>().map_err(|_| ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", "unknown type".to_string())],
@@ -1380,7 +1381,8 @@ pub static CREATE: CommandNode = CommandNode {
ast_builder: build_create_table,
help_id: Some("ddl.create"),
hint_ids: &["create_table"],
usage_ids: &["parse.usage.create_table"],};
usage_ids: &["parse.usage.create_table"],
};
// =================================================================
// create_m2n — `create m:n relationship from <T1> to <T2> [as <name>]`
@@ -1506,11 +1508,15 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result<Command, V
while let Some(item) = items.next() {
match &item.kind {
// A column name stashes until its type finalises the spec.
MatchedKind::Ident { role: "col_name", .. } => {
MatchedKind::Ident {
role: "col_name", ..
} => {
pending_name = Some(item.text.clone());
}
// Single-word type — resolve through the SQL alias map.
MatchedKind::Ident { role: "col_type", .. } => {
MatchedKind::Ident {
role: "col_type", ..
} => {
let ty = Type::from_sql_name(&item.text).ok_or_else(|| ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", "unknown type".to_string())],
@@ -1533,7 +1539,9 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result<Command, V
column_open = true;
}
// A table-level `PRIMARY KEY (col, …)` column reference.
MatchedKind::Ident { role: "pk_column", .. } => {
MatchedKind::Ident {
role: "pk_column", ..
} => {
primary_key.push(item.text.clone());
}
// `not null` column constraint (only once a column exists;
@@ -1557,7 +1565,10 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result<Command, V
let mut cols: Vec<String> = Vec::new();
while let Some(it) = items.peek() {
match &it.kind {
MatchedKind::Ident { role: "unique_column", .. } => {
MatchedKind::Ident {
role: "unique_column",
..
} => {
cols.push(it.text.clone());
items.next();
}
@@ -1575,7 +1586,10 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result<Command, V
// column's flag (round-trips via the single-column
// path); composite (or a name not among the
// columns) becomes a constraint.
match columns.iter_mut().find(|c| cols.len() == 1 && c.name == cols[0]) {
match columns
.iter_mut()
.find(|c| cols.len() == 1 && c.name == cols[0])
{
Some(c) => c.unique = true,
None if !cols.is_empty() => unique_constraints.push(cols),
None => {}
@@ -1588,16 +1602,17 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result<Command, V
// the most recent column) or the table-level clause (whose
// `pk_column` idents follow and are collected above).
MatchedKind::Word("primary") => {
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("key"))) {
if matches!(
items.peek().map(|i| &i.kind),
Some(MatchedKind::Word("key"))
) {
items.next();
// Table-level `PRIMARY KEY (…)` is followed by `(`
// (then `pk_column` idents, collected above);
// column-level `PRIMARY KEY` is not, and marks the
// most-recent column.
let table_level = matches!(
items.peek().map(|i| &i.kind),
Some(MatchedKind::Punct('('))
);
let table_level =
matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct('(')));
if !table_level && let Some(last) = columns.last() {
primary_key.push(last.name.clone());
}
@@ -1647,12 +1662,20 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result<Command, V
// Inline FK is single-column (the column it sits on);
// a compound FK uses the table-level form (ADR-0043 D4).
let child_column = columns.last().map_or_else(String::new, |c| c.name.clone());
foreign_keys.push(consume_fk_reference(&mut items, None, vec![child_column], true));
foreign_keys.push(consume_fk_reference(
&mut items,
None,
vec![child_column],
true,
));
}
// Table-level `[constraint <name>] foreign key (<col>)
// references <parent> [(<col>)] [on …]` (ADR-0035 §5, 4b).
MatchedKind::Word("foreign") => {
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("key"))) {
if matches!(
items.peek().map(|i| &i.kind),
Some(MatchedKind::Word("key"))
) {
items.next(); // `key`
}
// `( <child column> [, <child column>]* )` — a compound
@@ -1674,7 +1697,10 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result<Command, V
items.next();
}
// `references <parent> …`
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("references"))) {
if matches!(
items.peek().map(|i| &i.kind),
Some(MatchedKind::Word("references"))
) {
items.next();
}
let fk =
@@ -1859,13 +1885,19 @@ where
Some(MatchedKind::Word("cascade")) => ReferentialAction::Cascade,
Some(MatchedKind::Word("restrict")) => ReferentialAction::Restrict,
Some(MatchedKind::Word("set")) => {
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("null"))) {
if matches!(
items.peek().map(|i| &i.kind),
Some(MatchedKind::Word("null"))
) {
items.next();
}
ReferentialAction::SetNull
}
Some(MatchedKind::Word("no")) => {
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("action"))) {
if matches!(
items.peek().map(|i| &i.kind),
Some(MatchedKind::Word("action"))
) {
items.next();
}
ReferentialAction::NoAction
@@ -1933,11 +1965,12 @@ pub static SQL_DROP_INDEX: CommandNode = CommandNode {
// concrete keyword (`unique index` | `index`) — the trap-safe form (the
// §3 rule forbids a leading *Optional*, not a leading `Choice`). The
// builder reads `unique` presence via `contains_word("unique")`.
static SQL_CI_UNIQUE_INDEX_NODES: &[Node] =
&[Node::Word(Word::keyword("unique")), Node::Word(Word::keyword("index"))];
static SQL_CI_UNIQUE_INDEX_NODES: &[Node] = &[
Node::Word(Word::keyword("unique")),
Node::Word(Word::keyword("index")),
];
const SQL_CI_UNIQUE_INDEX: Node = Node::Seq(SQL_CI_UNIQUE_INDEX_NODES);
static SQL_CI_LEAD_CHOICES: &[Node] =
&[SQL_CI_UNIQUE_INDEX, Node::Word(Word::keyword("index"))];
static SQL_CI_LEAD_CHOICES: &[Node] = &[SQL_CI_UNIQUE_INDEX, Node::Word(Word::keyword("index"))];
const SQL_CI_LEAD: Node = Node::Choice(SQL_CI_LEAD_CHOICES);
static SQL_CI_IF_NOT_EXISTS_NODES: &[Node] = &[
@@ -2104,8 +2137,7 @@ static AT_RENAME_COLUMN_TAIL_NODES: &[Node] = &[
NEW_COLUMN_NAME,
];
const AT_RENAME_COLUMN_TAIL: Node = Node::Seq(AT_RENAME_COLUMN_TAIL_NODES);
static AT_RENAME_TABLE_TAIL_NODES: &[Node] =
&[Node::Word(Word::keyword("to")), NEW_TABLE_NAME];
static AT_RENAME_TABLE_TAIL_NODES: &[Node] = &[Node::Word(Word::keyword("to")), NEW_TABLE_NAME];
const AT_RENAME_TABLE_TAIL: Node = Node::Seq(AT_RENAME_TABLE_TAIL_NODES);
static AT_RENAME_TAIL_CHOICES: &[Node] = &[AT_RENAME_COLUMN_TAIL, AT_RENAME_TABLE_TAIL];
const AT_RENAME_TAIL: Node = Node::Choice(AT_RENAME_TAIL_CHOICES);
@@ -2132,8 +2164,10 @@ static AT_AC_TYPE_NODES: &[Node] = &[
super::sql_create_table::SQL_TYPE,
];
const AT_AC_TYPE: Node = Node::Seq(AT_AC_TYPE_NODES);
static AT_AC_NOT_NULL_NODES: &[Node] =
&[Node::Word(Word::keyword("not")), Node::Word(Word::keyword("null"))];
static AT_AC_NOT_NULL_NODES: &[Node] = &[
Node::Word(Word::keyword("not")),
Node::Word(Word::keyword("null")),
];
const AT_AC_NOT_NULL: Node = Node::Seq(AT_AC_NOT_NULL_NODES);
static AT_AC_SET_DATA_TYPE_NODES: &[Node] = &[
Node::Word(Word::keyword("data")),
@@ -2149,8 +2183,7 @@ static AT_AC_SET_TAIL_CHOICES: &[Node] = &[
const AT_AC_SET_TAIL: Node = Node::Choice(AT_AC_SET_TAIL_CHOICES);
static AT_AC_SET_NODES: &[Node] = &[Node::Word(Word::keyword("set")), AT_AC_SET_TAIL];
const AT_AC_SET: Node = Node::Seq(AT_AC_SET_NODES);
static AT_AC_DROP_TAIL_CHOICES: &[Node] =
&[AT_AC_NOT_NULL, Node::Word(Word::keyword("default"))];
static AT_AC_DROP_TAIL_CHOICES: &[Node] = &[AT_AC_NOT_NULL, Node::Word(Word::keyword("default"))];
const AT_AC_DROP_TAIL: Node = Node::Choice(AT_AC_DROP_TAIL_CHOICES);
static AT_AC_DROP_NODES: &[Node] = &[Node::Word(Word::keyword("drop")), AT_AC_DROP_TAIL];
const AT_AC_DROP: Node = Node::Seq(AT_AC_DROP_NODES);
@@ -2258,10 +2291,14 @@ fn build_alter_add_column_spec(
let mut items = path.items.iter().peekable();
while let Some(item) = items.next() {
match &item.kind {
MatchedKind::Ident { role: "col_name", .. } => {
MatchedKind::Ident {
role: "col_name", ..
} => {
pending_name = Some(item.text.clone());
}
MatchedKind::Ident { role: "col_type", .. } => {
MatchedKind::Ident {
role: "col_type", ..
} => {
let ty = Type::from_sql_name(&item.text).ok_or_else(|| ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", "unknown type".to_string())],
@@ -2280,7 +2317,10 @@ fn build_alter_add_column_spec(
spec = Some(ColumnSpec::new(name, Type::Real));
}
MatchedKind::Word("not") => {
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("null"))) {
if matches!(
items.peek().map(|i| &i.kind),
Some(MatchedKind::Word("null"))
) {
items.next();
if let Some(s) = spec.as_mut() {
s.not_null = true;
@@ -2326,11 +2366,15 @@ fn build_alter_column_type(path: &MatchedPath) -> Result<AlterTableAction, Valid
let mut items = path.items.iter().peekable();
while let Some(item) = items.next() {
match &item.kind {
MatchedKind::Ident { role: "col_type", .. } => {
ty = Some(Type::from_sql_name(&item.text).ok_or_else(|| ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", "unknown type".to_string())],
})?);
MatchedKind::Ident {
role: "col_type", ..
} => {
ty = Some(
Type::from_sql_name(&item.text).ok_or_else(|| ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", "unknown type".to_string())],
})?,
);
}
MatchedKind::Word("double") => {
if matches!(
@@ -2379,7 +2423,10 @@ fn build_alter_column_attr(
message_key: "parse.error_wrapper",
args: vec![("detail", "set default needs a value".to_string())],
})?;
AlterTableAction::SetColumnDefault { column, default_sql }
AlterTableAction::SetColumnDefault {
column,
default_sql,
}
}
(false, true) => AlterTableAction::DropColumnDefault { column },
(true, false) => AlterTableAction::SetColumnNotNull { column },
@@ -2495,10 +2542,7 @@ fn build_alter_add_table_constraint(
/// Capture the raw SQL text of an `ADD … CHECK (<expr>)` (ADR-0035 §4g).
/// `sql_expr` is validate-only, so the expression is captured by byte
/// span — the 4a.2 / 4e mechanism.
fn capture_table_check_sql(
path: &MatchedPath,
source: &str,
) -> Result<String, ValidationError> {
fn capture_table_check_sql(path: &MatchedPath, source: &str) -> Result<String, ValidationError> {
let mut items = path.items.iter().peekable();
while let Some(item) = items.next() {
if matches!(item.kind, MatchedKind::Word("check"))
@@ -2528,7 +2572,10 @@ fn build_alter_fk(path: &MatchedPath) -> SqlForeignKey {
items.next();
}
items.next(); // `foreign`
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("key"))) {
if matches!(
items.peek().map(|i| &i.kind),
Some(MatchedKind::Word("key"))
) {
items.next();
}
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct('('))) {
@@ -2548,7 +2595,10 @@ fn build_alter_fk(path: &MatchedPath) -> SqlForeignKey {
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct(')'))) {
items.next();
}
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("references"))) {
if matches!(
items.peek().map(|i| &i.kind),
Some(MatchedKind::Word("references"))
) {
items.next();
}
// `ALTER TABLE … ADD FOREIGN KEY (…)` is the table-level form.
@@ -2626,7 +2676,10 @@ mod constraint_tests {
fn an_unconstrained_create_table_still_parses() {
let cols = create_columns("create table T with pk id(serial), name(text)");
assert_eq!(cols.len(), 2);
assert!(cols.iter().all(|c| !c.not_null && !c.unique && c.default.is_none()));
assert!(
cols.iter()
.all(|c| !c.not_null && !c.unique && c.default.is_none())
);
}
#[test]
@@ -2651,7 +2704,9 @@ mod constraint_tests {
#[test]
fn add_column_parses_a_unique_constraint() {
match parse_command("add column to T: email (text) unique").expect("parse") {
Command::AddColumn { unique, not_null, .. } => {
Command::AddColumn {
unique, not_null, ..
} => {
assert!(unique);
assert!(!not_null);
}
@@ -2682,9 +2737,7 @@ mod constraint_tests {
fn check_with_a_parenthesised_sub_expression_parses() {
// The check's own parens plus a nested group — the
// builder's paren-depth scan must pair them correctly.
let cols = create_columns(
"create table T with pk n(int) check ((n > 0) or (n < -10))",
);
let cols = create_columns("create table T with pk n(int) check ((n > 0) or (n < -10))");
assert!(cols[0].check.is_some());
}
@@ -2731,8 +2784,7 @@ mod constraint_tests {
#[test]
fn add_constraint_check_parses() {
match parse_command("add constraint check (age >= 0) to Users.age").expect("parse")
{
match parse_command("add constraint check (age >= 0) to Users.age").expect("parse") {
Command::AddConstraint {
column, constraint, ..
} => {
@@ -2826,8 +2878,11 @@ mod sql_drop_table_tests {
Command::DropColumn { .. }
));
assert!(matches!(
parse_command_in_mode("drop relationship Customers_id_to_Orders_CustId", Mode::Advanced)
.expect("parses"),
parse_command_in_mode(
"drop relationship Customers_id_to_Orders_CustId",
Mode::Advanced
)
.expect("parses"),
Command::DropRelationship { .. }
));
}
@@ -2932,7 +2987,13 @@ mod sql_create_index_tests {
columns,
unique,
if_not_exists,
} => Ci { name, table, columns, unique, if_not_exists },
} => Ci {
name,
table,
columns,
unique,
if_not_exists,
},
other => panic!("expected SqlCreateIndex, got {other:?}"),
}
}
@@ -3134,7 +3195,9 @@ mod sql_alter_table_tests {
// The target slot carries the `reject_internal_table` validator
// (mirroring CREATE TABLE), so an `__rdbms_*` target is refused
// before submit — engine-neutral, not a raw engine error.
assert!(parse_command_in_mode("alter table T rename to __rdbms_evil", Mode::Advanced).is_err());
assert!(
parse_command_in_mode("alter table T rename to __rdbms_evil", Mode::Advanced).is_err()
);
}
#[test]
@@ -3213,7 +3276,10 @@ mod sql_alter_table_tests {
// alias map still applies through the synonym
assert!(matches!(
alter("alter table T alter column n set data type double precision").1,
AlterTableAction::AlterColumnType { ty: crate::dsl::types::Type::Real, .. }
AlterTableAction::AlterColumnType {
ty: crate::dsl::types::Type::Real,
..
}
));
}
@@ -3238,7 +3304,10 @@ mod sql_alter_table_tests {
#[test]
fn alter_column_set_default_captures_raw_expr() {
match alter("alter table T alter column qty set default 0").1 {
AlterTableAction::SetColumnDefault { column, default_sql } => {
AlterTableAction::SetColumnDefault {
column,
default_sql,
} => {
assert_eq!(column, "qty");
assert_eq!(default_sql, "0");
}
@@ -3317,7 +3386,9 @@ mod sql_alter_table_tests {
match alter("alter table T add check (a < b)").1 {
AlterTableAction::AddTableConstraint { name, constraint } => {
assert_eq!(name, None);
assert!(matches!(*constraint, TableConstraint::Check { ref expr_sql } if expr_sql == "a < b"));
assert!(
matches!(*constraint, TableConstraint::Check { ref expr_sql } if expr_sql == "a < b")
);
}
other => panic!("expected AddTableConstraint/Check, got {other:?}"),
}
@@ -3335,7 +3406,9 @@ mod sql_alter_table_tests {
match alter("alter table T add unique (a, b)").1 {
AlterTableAction::AddTableConstraint { name, constraint } => {
assert_eq!(name, None);
assert!(matches!(*constraint, TableConstraint::Unique { ref columns } if columns == &["a".to_string(), "b".to_string()]));
assert!(
matches!(*constraint, TableConstraint::Unique { ref columns } if columns == &["a".to_string(), "b".to_string()])
);
}
other => panic!("expected AddTableConstraint/Unique, got {other:?}"),
}
@@ -3352,7 +3425,9 @@ mod sql_alter_table_tests {
)
.expect_err("a named UNIQUE constraint is refused");
assert!(
err.to_string().to_lowercase().contains("unique constraint cannot be named"),
err.to_string()
.to_lowercase()
.contains("unique constraint cannot be named"),
"expected the builder's named-UNIQUE refusal, got: {err}"
);
}
@@ -3364,7 +3439,9 @@ mod sql_alter_table_tests {
let err = parse_command_in_mode("alter table T add primary key (id)", Mode::Advanced)
.expect_err("ADD PRIMARY KEY is refused");
assert!(
err.to_string().to_lowercase().contains("primary key is fixed at creation"),
err.to_string()
.to_lowercase()
.contains("primary key is fixed at creation"),
"expected the builder's ADD-PRIMARY-KEY refusal, got: {err}"
);
}
@@ -3392,7 +3469,10 @@ mod sql_alter_table_tests {
assert_eq!(name.as_deref(), Some("fk_p"));
match *constraint {
TableConstraint::ForeignKey(fk) => {
assert_eq!(fk.parent_columns, None, "bare reference resolves at execution");
assert_eq!(
fk.parent_columns, None,
"bare reference resolves at execution"
);
}
other => panic!("expected ForeignKey, got {other:?}"),
}
+24 -35
View File
@@ -79,9 +79,9 @@ const EXPR_COLUMN: Node = Node::Ident {
writes_table: false,
writes_column: true,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
/// Operand alternatives. The literal keywords (`null` / `true`
@@ -126,8 +126,7 @@ fn where_rhs_operand(ctx: &WalkContext) -> Node {
// the leak is per distinct column (the walker
// memoizes `DynamicSubgrammar` resolution on
// `current_column`), not per keystroke.
let leaked: &'static str =
Box::leak(col.name.clone().into_boxed_str());
let leaked: &'static str = Box::leak(col.name.clone().into_boxed_str());
Node::TypedValueSlot {
ty: col.user_type,
column_name: Some(leaked),
@@ -260,10 +259,8 @@ static PAREN_GROUP_NODES: &[Node] = &[
Node::Subgrammar(&OR_EXPR),
Node::Punct(')'),
];
static BOOL_PRIMARY_CHOICES: &[Node] = &[
Node::Seq(PAREN_GROUP_NODES),
Node::Subgrammar(&PREDICATE),
];
static BOOL_PRIMARY_CHOICES: &[Node] =
&[Node::Seq(PAREN_GROUP_NODES), Node::Subgrammar(&PREDICATE)];
static BOOL_PRIMARY: Node = Node::Choice(BOOL_PRIMARY_CHOICES);
/// `not_expr := NOT not_expr | bool_primary`.
@@ -271,10 +268,7 @@ static NOT_FORM_NODES: &[Node] = &[
Node::Word(Word::keyword("not")),
Node::Subgrammar(&NOT_EXPR),
];
static NOT_EXPR_CHOICES: &[Node] = &[
Node::Seq(NOT_FORM_NODES),
Node::Subgrammar(&BOOL_PRIMARY),
];
static NOT_EXPR_CHOICES: &[Node] = &[Node::Seq(NOT_FORM_NODES), Node::Subgrammar(&BOOL_PRIMARY)];
static NOT_EXPR: Node = Node::Choice(NOT_EXPR_CHOICES);
/// `and_expr := not_expr ( AND not_expr )*`.
@@ -296,10 +290,7 @@ static AND_EXPR: Node = Node::Seq(AND_EXPR_NODES);
/// `or_expr := and_expr ( OR and_expr )*` — the fragment entry
/// point. `update` / `delete` / `show data` reference this
/// through `Node::Subgrammar(&OR_EXPR)`.
static OR_TAIL_NODES: &[Node] = &[
Node::Word(Word::keyword("or")),
Node::Subgrammar(&AND_EXPR),
];
static OR_TAIL_NODES: &[Node] = &[Node::Word(Word::keyword("or")), Node::Subgrammar(&AND_EXPR)];
static OR_TAIL: Node = Node::Seq(OR_TAIL_NODES);
static OR_EXPR_NODES: &[Node] = &[
Node::Subgrammar(&AND_EXPR),
@@ -534,18 +525,18 @@ impl<'a> ExprParser<'a> {
let span = item.span;
let literal = |value: Value| Operand::Literal { value, span };
match &item.kind {
MatchedKind::Ident { role: "expr_column", .. } => {
Ok(Operand::Column { name: item.text.clone(), span })
}
MatchedKind::Ident {
role: "expr_column",
..
} => Ok(Operand::Column {
name: item.text.clone(),
span,
}),
MatchedKind::Word("null") => Ok(literal(Value::Null)),
MatchedKind::Word("true") => Ok(literal(Value::Bool(true))),
MatchedKind::Word("false") => Ok(literal(Value::Bool(false))),
MatchedKind::NumberLit => {
Ok(literal(Value::Number(item.text.clone())))
}
MatchedKind::StringLit => {
Ok(literal(Value::Text(item.text.clone())))
}
MatchedKind::NumberLit => Ok(literal(Value::Number(item.text.clone()))),
MatchedKind::StringLit => Ok(literal(Value::Text(item.text.clone()))),
_ => Err(drift_error("expected a column or literal operand")),
}
}
@@ -591,8 +582,7 @@ mod tests {
let mut ctx = WalkContext::new();
let mut path = MatchedPath::new();
let mut per_byte = Vec::new();
let result =
walk_node(input, 0, &OR_EXPR, &mut ctx, &mut path, &mut per_byte);
let result = walk_node(input, 0, &OR_EXPR, &mut ctx, &mut path, &mut per_byte);
match result {
NodeWalkResult::Matched { end, .. } => {
assert!(
@@ -730,8 +720,7 @@ mod tests {
negated: false,
}),
);
let Expr::Predicate(Predicate::Like { negated, .. }) =
parse_expr("Name not like 'A%'")
let Expr::Predicate(Predicate::Like { negated, .. }) = parse_expr("Name not like 'A%'")
else {
panic!("expected a negated Like");
};
@@ -794,16 +783,16 @@ mod tests {
fn nested_parentheses_round_trip() {
// Exercises the Subgrammar recursion a few levels deep.
let expr = parse_expr("((a = 1 and b = 2) or (c = 3))");
assert!(matches!(expr, Expr::Or(_) | Expr::And(_) | Expr::Predicate(_)));
assert!(matches!(
expr,
Expr::Or(_) | Expr::And(_) | Expr::Predicate(_)
));
}
#[test]
fn case_insensitive_keywords() {
// Keywords fold case; the built tree is identical.
assert_eq!(
parse_expr("a = 1 AND b = 2"),
parse_expr("a = 1 and b = 2"),
);
assert_eq!(parse_expr("a = 1 AND b = 2"), parse_expr("a = 1 and b = 2"),);
assert_eq!(
parse_expr("Email IS NOT NULL"),
parse_expr("Email is not null"),
+20 -24
View File
@@ -27,9 +27,9 @@ pub mod data;
pub mod ddl;
pub mod expr;
pub mod shared;
pub mod sql_expr;
pub mod sql_create_table;
pub mod sql_delete;
pub mod sql_expr;
pub mod sql_insert;
pub mod sql_select;
pub mod sql_update;
@@ -328,9 +328,7 @@ pub enum Node {
/// A number literal. The optional `validator` runs against
/// the matched text (used by Phase D value slots to enforce
/// per-type integer/decimal rules).
NumberLit {
validator: Option<NumberValidator>,
},
NumberLit { validator: Option<NumberValidator> },
/// A literal byte sequence at this position — matches
/// bytes verbatim (whitespace-skipped) with a lookahead so
/// `1` doesn't half-match `12` and `n` doesn't half-match
@@ -701,7 +699,11 @@ fn selected_nodes_for_input_in_mode(
.filter(|(_, _, c)| *c == CommandCategory::Simple)
.collect()
};
if selected.is_empty() { candidates } else { selected }
if selected.is_empty() {
candidates
} else {
selected
}
}
/// The single usage template most relevant to `source`, when
@@ -724,10 +726,7 @@ pub fn usage_key_for_input(source: &str) -> Option<&'static str> {
/// disambiguates the single most-relevant usage key from the
/// mode-selected key set.
#[must_use]
pub fn usage_key_for_input_in_mode(
source: &str,
mode: crate::mode::Mode,
) -> Option<&'static str> {
pub fn usage_key_for_input_in_mode(source: &str, mode: crate::mode::Mode) -> Option<&'static str> {
let (_entry, keys) = usage_keys_for_input_in_mode(source, mode)?;
pick_form_key(source, &keys)
}
@@ -755,7 +754,10 @@ fn pick_form_key<'a>(source: &str, keys: &[&'a str]) -> Option<&'a str> {
}
// The `create m:n relationship` form (ADR-0045) opens with `m:n`
// — a letter, so the digit branch misses it; its key ends `…m2n`.
if source[after..].get(..3).is_some_and(|s| s.eq_ignore_ascii_case("m:n")) {
if source[after..]
.get(..3)
.is_some_and(|s| s.eq_ignore_ascii_case("m:n"))
{
return keys.iter().copied().find(|k| k.ends_with("m2n"));
}
// Otherwise the form word is an identifier — `column`, `index`,
@@ -770,8 +772,7 @@ fn pick_form_key<'a>(source: &str, keys: &[&'a str]) -> Option<&'a str> {
/// which read the same data through the legacy `usage::REGISTRY`.
#[must_use]
pub fn entry_words_alphabetised() -> Vec<&'static str> {
let mut words: Vec<&'static str> =
REGISTRY.iter().map(|(c, _)| c.entry.primary).collect();
let mut words: Vec<&'static str> = REGISTRY.iter().map(|(c, _)| c.entry.primary).collect();
words.sort_unstable();
words.dedup();
words
@@ -905,9 +906,7 @@ pub fn command_for_entry_word(word: &str) -> Option<(usize, &'static CommandNode
/// returns its `Simple` DSL node and `Advanced` SQL node. The
/// dispatcher picks among them by the active input mode.
#[must_use]
pub fn commands_for_entry_word(
word: &str,
) -> Vec<(usize, &'static CommandNode, CommandCategory)> {
pub fn commands_for_entry_word(word: &str) -> Vec<(usize, &'static CommandNode, CommandCategory)> {
REGISTRY
.iter()
.enumerate()
@@ -1010,7 +1009,10 @@ mod hint_key_tests {
];
for c in classes {
let key = format!("hint.err.{c}.what");
assert!(cat.get(&key).is_some(), "missing tier-3 error block `{key}`");
assert!(
cat.get(&key).is_some(),
"missing tier-3 error block `{key}`"
);
}
}
@@ -1098,10 +1100,7 @@ mod usage_key_tests {
let cases = [
("add column to T: c (int)", "parse.usage.add_column"),
("add index on T (c)", "parse.usage.add_index"),
(
"add constraint unique to T.c",
"parse.usage.add_constraint",
),
("add constraint unique to T.c", "parse.usage.add_constraint"),
(
"drop constraint check from T.c",
"parse.usage.drop_constraint",
@@ -1118,10 +1117,7 @@ mod usage_key_tests {
("drop table T", "parse.usage.drop_table"),
("drop column from table T: c", "parse.usage.drop_column"),
("drop index i", "parse.usage.drop_index"),
(
"drop relationship r",
"parse.usage.drop_relationship",
),
("drop relationship r", "parse.usage.drop_relationship"),
("show data T", "parse.usage.show_data"),
("show table T", "parse.usage.show_table"),
// `create` is multi-form (table vs m:n, ADR-0045): each typed
+17 -24
View File
@@ -7,8 +7,8 @@
use crate::completion::TableColumn;
use crate::dsl::grammar::{
HighlightClass, HintMode, IdentSource, IdentValidator, Node,
NumberValidator, ValidationError, Word,
HighlightClass, HintMode, IdentSource, IdentValidator, Node, NumberValidator, ValidationError,
Word,
};
use crate::dsl::types::Type;
use crate::dsl::walker::context::WalkContext;
@@ -32,10 +32,7 @@ pub fn validate_type_name(value: &str) -> Result<(), ValidationError> {
.join(", ");
Err(ValidationError {
message_key: "parse.custom.unknown_type",
args: vec![
("found", value.to_string()),
("expected", expected),
],
args: vec![("found", value.to_string()), ("expected", expected)],
})
}
}
@@ -51,12 +48,12 @@ pub const TYPE_SLOT: Node = Node::Ident {
role: "type",
validator: Some(TYPE_VALIDATOR),
highlight_override: Some(HighlightClass::Type),
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
// --- Qualified column reference (`<Table>.<Column>`) --------------
@@ -70,9 +67,9 @@ const QUALIFIED_COLUMN_NODES: &[Node] = &[
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
},
Node::Punct('.'),
Node::Ident {
@@ -83,9 +80,9 @@ const QUALIFIED_COLUMN_NODES: &[Node] = &[
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
},
];
pub const QUALIFIED_COLUMN: Node = Node::Seq(QUALIFIED_COLUMN_NODES);
@@ -313,9 +310,7 @@ const fn slot_inner_for_type(ty: Type) -> &'static Node {
Type::Real => &REAL_SLOT_INNER,
Type::Decimal => &DECIMAL_SLOT_INNER,
Type::Bool => &BOOL_SLOT_INNER,
Type::Text | Type::Date | Type::DateTime | Type::Blob | Type::ShortId => {
&TEXT_SLOT_INNER
}
Type::Text | Type::Date | Type::DateTime | Type::Blob | Type::ShortId => &TEXT_SLOT_INNER,
}
}
@@ -397,9 +392,7 @@ pub(crate) const FALLBACK_VALUE_LIST: Node = Node::Repeated {
/// This is the single source of truth shared by [`column_value_list`]
/// (which builds the typed slots) and the `data.rs` arity gate (which
/// counts them) so the two never disagree (issue #17).
pub fn insert_target_columns<'c>(
ctx: &'c WalkContext<'_>,
) -> Option<Vec<&'c TableColumn>> {
pub fn insert_target_columns<'c>(ctx: &'c WalkContext<'_>) -> Option<Vec<&'c TableColumn>> {
let table_cols = ctx.current_table_columns.as_ref()?;
if table_cols.is_empty() {
return None;
+72 -22
View File
@@ -405,8 +405,14 @@ const TABLE_FK_NAMED: Node = Node::Seq(TABLE_FK_NAMED_NODES);
// / `foreign`) that disambiguates it from a column name. (A column
// literally named with one of those keywords is therefore unavailable,
// the same trade real SQL makes with its reserved words.)
static ELEMENT_CHOICES: &[Node] =
&[TABLE_PK, TABLE_UNIQUE, TABLE_CHECK, TABLE_FK_NAMED, TABLE_FK, COLUMN_DEF];
static ELEMENT_CHOICES: &[Node] = &[
TABLE_PK,
TABLE_UNIQUE,
TABLE_CHECK,
TABLE_FK_NAMED,
TABLE_FK,
COLUMN_DEF,
];
const ELEMENT_INNER: Node = Node::Choice(ELEMENT_CHOICES);
// Issue #4: wrap the element slot in `IntroProse` so a fresh element
// position (`create table T (` and after every `,`) surfaces a prose
@@ -495,18 +501,31 @@ mod tests {
let mut ctx = WalkContext::new();
let mut path = MatchedPath::new();
let mut per_byte = Vec::new();
match walk_node(input, 0, &SQL_CREATE_TABLE_SHAPE, &mut ctx, &mut path, &mut per_byte) {
match walk_node(
input,
0,
&SQL_CREATE_TABLE_SHAPE,
&mut ctx,
&mut path,
&mut per_byte,
) {
NodeWalkResult::Matched { end, .. } => input[end..].trim().is_empty(),
_ => false,
}
}
fn good(input: &str) {
assert!(walks(input), "{input:?} should be a valid CREATE TABLE tail");
assert!(
walks(input),
"{input:?} should be a valid CREATE TABLE tail"
);
}
fn bad(input: &str) {
assert!(!walks(input), "{input:?} should NOT walk as a complete CREATE TABLE tail");
assert!(
!walks(input),
"{input:?} should NOT walk as a complete CREATE TABLE tail"
);
}
#[test]
@@ -638,7 +657,9 @@ mod tests {
good("table t (id int, ref int references other(id))");
good("table t (id int, ref int references other)"); // bare ref
good("table t (id int, ref int references other(id) on delete cascade)");
good("table t (id int, ref int references other(id) on update set null on delete restrict)");
good(
"table t (id int, ref int references other(id) on update set null on delete restrict)",
);
good("table t (id int, ref int, foreign key (ref) references other(id))");
good("table t (id int, ref int, constraint fk_x foreign key (ref) references other(id))");
good(
@@ -691,7 +712,10 @@ mod builder_tests {
assert_eq!(name, "t");
assert_eq!(
cols,
vec![("id".to_string(), Type::Int), ("name".to_string(), Type::Text)]
vec![
("id".to_string(), Type::Int),
("name".to_string(), Type::Text)
]
);
assert!(pk.is_empty(), "no PK declared");
assert!(!ine);
@@ -740,7 +764,10 @@ mod builder_tests {
let (_, cols, _, _) = sct("create table t (a varchar(255), b numeric(10, 2))");
assert_eq!(
cols,
vec![("a".to_string(), Type::Text), ("b".to_string(), Type::Decimal)]
vec![
("a".to_string(), Type::Text),
("b".to_string(), Type::Decimal)
]
);
}
@@ -780,8 +807,7 @@ mod builder_tests {
fn redundant_constraints_deduped_off_sole_pk_column() {
// ADR-0035 §6.5: advanced mode accepts the redundant spelling
// and silently drops the flags off the sole PK column.
match parse_command("create table t (id int primary key not null unique)")
.expect("parses")
match parse_command("create table t (id int primary key not null unique)").expect("parses")
{
Command::SqlCreateTable {
columns,
@@ -944,8 +970,7 @@ mod builder_tests {
// depth 2, not an element boundary, so the following `check`
// is still column-level. A naive "reset on any comma" would
// misclassify it as table-level (the §4.2 probe).
let (cols, checks) =
parse_sct_checks("create table t (n numeric(10, 2) check (n > 0))");
let (cols, checks) = parse_sct_checks("create table t (n numeric(10, 2) check (n > 0))");
assert_eq!(col(&cols, "n").check_sql.as_deref(), Some("n > 0"));
assert!(checks.is_empty(), "no table-level CHECK was produced");
}
@@ -977,8 +1002,7 @@ mod builder_tests {
fn table_check_before_a_later_column_is_table_level() {
// A CHECK element that appears between columns (not after a
// column's type) is table-level even though more columns follow.
let (cols, checks) =
parse_sct_checks("create table t (a int, check (a > 0), b int)");
let (cols, checks) = parse_sct_checks("create table t (a int, check (a > 0), b int)");
assert_eq!(checks, vec!["a > 0".to_string()]);
assert!(col(&cols, "a").check_sql.is_none() && col(&cols, "b").check_sql.is_none());
}
@@ -1004,7 +1028,10 @@ mod builder_tests {
assert_eq!(fk.parent_columns, Some(vec!["id".to_string()]));
assert_eq!(fk.on_delete, ReferentialAction::NoAction);
assert_eq!(fk.on_update, ReferentialAction::NoAction);
assert!(fk.inline, "a column-level `references` is an inline FK (ADR-0043 D4)");
assert!(
fk.inline,
"a column-level `references` is an inline FK (ADR-0043 D4)"
);
}
#[test]
@@ -1012,14 +1039,19 @@ mod builder_tests {
// The table-level `FOREIGN KEY (...)` form is not inline, so it can
// carry a multi-column reference and never triggers the inline
// "use the table-level form" hint (ADR-0043 D4).
let fks = parse_sct_fks("create table t (id int, pid int, foreign key (pid) references parent(id))");
let fks = parse_sct_fks(
"create table t (id int, pid int, foreign key (pid) references parent(id))",
);
assert!(!fks[0].inline, "table-level FOREIGN KEY is not inline");
}
#[test]
fn bare_inline_reference_has_no_parent_column() {
let fks = parse_sct_fks("create table t (id int, pid int references parent)");
assert_eq!(fks[0].parent_columns, None, "bare REFERENCES — resolved at execution");
assert_eq!(
fks[0].parent_columns, None,
"bare REFERENCES — resolved at execution"
);
assert_eq!(fks[0].parent_table, "parent");
assert_eq!(fks[0].child_columns, vec!["pid".to_string()]);
}
@@ -1047,8 +1079,9 @@ mod builder_tests {
#[test]
fn table_level_foreign_key_captured() {
let fks =
parse_sct_fks("create table t (id int, pid int, foreign key (pid) references parent(id))");
let fks = parse_sct_fks(
"create table t (id int, pid int, foreign key (pid) references parent(id))",
);
assert_eq!(fks.len(), 1);
assert_eq!(fks[0].name, None);
assert_eq!(fks[0].child_columns, vec!["pid".to_string()]);
@@ -1073,8 +1106,20 @@ mod builder_tests {
foreign key (a) references p(id), foreign key (b) references q(id))",
);
assert_eq!(fks.len(), 2);
assert_eq!((fks[0].child_columns[0].as_str(), fks[0].parent_table.as_str()), ("a", "p"));
assert_eq!((fks[1].child_columns[0].as_str(), fks[1].parent_table.as_str()), ("b", "q"));
assert_eq!(
(
fks[0].child_columns[0].as_str(),
fks[0].parent_table.as_str()
),
("a", "p")
);
assert_eq!(
(
fks[1].child_columns[0].as_str(),
fks[1].parent_table.as_str()
),
("b", "q")
);
}
#[test]
@@ -1108,7 +1153,12 @@ mod builder_tests {
assert_eq!(foreign_keys[0].child_columns, vec!["pid".to_string()]);
// the column-level CHECK still attaches to `pid`
assert_eq!(
columns.iter().find(|c| c.name == "pid").unwrap().check_sql.as_deref(),
columns
.iter()
.find(|c| c.name == "pid")
.unwrap()
.check_sql
.as_deref(),
Some("pid > 0")
);
// the table-level CHECK is captured separately
+12 -2
View File
@@ -82,7 +82,14 @@ mod tests {
let mut ctx = WalkContext::new();
let mut path = MatchedPath::new();
let mut per_byte = Vec::new();
match walk_node(input, 0, &SQL_DELETE_SHAPE, &mut ctx, &mut path, &mut per_byte) {
match walk_node(
input,
0,
&SQL_DELETE_SHAPE,
&mut ctx,
&mut path,
&mut per_byte,
) {
NodeWalkResult::Matched { end, .. } => input[end..].trim().is_empty(),
_ => false,
}
@@ -93,7 +100,10 @@ mod tests {
}
fn bad(input: &str) {
assert!(!walks(input), "{input:?} should NOT walk as a complete DELETE tail");
assert!(
!walks(input),
"{input:?} should NOT walk as a complete DELETE tail"
);
}
#[test]
+31 -63
View File
@@ -82,19 +82,16 @@ const EXPR_IDENT: Node = Node::Ident {
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
// =================================================================
// or_expr := and_expr ( OR and_expr )* — the fragment entry point
// =================================================================
static OR_TAIL_NODES: &[Node] = &[
Node::Word(Word::keyword("or")),
Node::Subgrammar(&AND_EXPR),
];
static OR_TAIL_NODES: &[Node] = &[Node::Word(Word::keyword("or")), Node::Subgrammar(&AND_EXPR)];
static OR_TAIL: Node = Node::Seq(OR_TAIL_NODES);
static SQL_OR_EXPR_NODES: &[Node] = &[
Node::Subgrammar(&AND_EXPR),
@@ -140,10 +137,7 @@ static NOT_FORM_NODES: &[Node] = &[
Node::Word(Word::keyword("not")),
Node::Subgrammar(&NOT_EXPR),
];
static NOT_EXPR_CHOICES: &[Node] = &[
Node::Seq(NOT_FORM_NODES),
Node::Subgrammar(&PREDICATE),
];
static NOT_EXPR_CHOICES: &[Node] = &[Node::Seq(NOT_FORM_NODES), Node::Subgrammar(&PREDICATE)];
static NOT_EXPR: Node = Node::Choice(NOT_EXPR_CHOICES);
// =================================================================
@@ -156,10 +150,7 @@ static NOT_EXPR: Node = Node::Choice(NOT_EXPR_CHOICES);
// needs. ADR-0026's DSL grammar made the tail mandatory because it
// forbade a bare column as a boolean; SQL does not.
static PREDICATE_NODES: &[Node] = &[
Node::Subgrammar(&ADDITIVE),
Node::Optional(&PREDICATE_TAIL),
];
static PREDICATE_NODES: &[Node] = &[Node::Subgrammar(&ADDITIVE), Node::Optional(&PREDICATE_TAIL)];
static PREDICATE: Node = Node::Seq(PREDICATE_NODES);
// ---- cmp_op := <= | <> | >= | != | < | > | = --------------------
@@ -181,10 +172,7 @@ static CMP_OP_CHOICES: &[Node] = &[
// ---- predicate_tail branches ------------------------------------
/// `cmp_op additive`.
static COMPARE_FORM_NODES: &[Node] = &[
Node::Choice(CMP_OP_CHOICES),
Node::Subgrammar(&ADDITIVE),
];
static COMPARE_FORM_NODES: &[Node] = &[Node::Choice(CMP_OP_CHOICES), Node::Subgrammar(&ADDITIVE)];
/// `IS [NOT] NULL`.
static IS_NULL_NODES: &[Node] = &[
@@ -265,11 +253,7 @@ static PREDICATE_TAIL: Node = Node::Choice(PREDICATE_TAIL_CHOICES);
// additive := multiplicative ( ( + | - | || ) multiplicative )*
// =================================================================
static ADD_OP_CHOICES: &[Node] = &[
Node::Punct('+'),
Node::Punct('-'),
Node::Literal("||"),
];
static ADD_OP_CHOICES: &[Node] = &[Node::Punct('+'), Node::Punct('-'), Node::Literal("||")];
static ADD_TAIL_NODES: &[Node] = &[
Node::Choice(ADD_OP_CHOICES),
Node::Subgrammar(&MULTIPLICATIVE),
@@ -289,15 +273,8 @@ static ADDITIVE: Node = Node::Seq(ADDITIVE_NODES);
// multiplicative := unary ( ( * | / | % ) unary )*
// =================================================================
static MUL_OP_CHOICES: &[Node] = &[
Node::Punct('*'),
Node::Punct('/'),
Node::Punct('%'),
];
static MUL_TAIL_NODES: &[Node] = &[
Node::Choice(MUL_OP_CHOICES),
Node::Subgrammar(&UNARY),
];
static MUL_OP_CHOICES: &[Node] = &[Node::Punct('*'), Node::Punct('/'), Node::Punct('%')];
static MUL_TAIL_NODES: &[Node] = &[Node::Choice(MUL_OP_CHOICES), Node::Subgrammar(&UNARY)];
static MUL_TAIL: Node = Node::Seq(MUL_TAIL_NODES);
static MULTIPLICATIVE_NODES: &[Node] = &[
Node::Subgrammar(&UNARY),
@@ -314,14 +291,8 @@ static MULTIPLICATIVE: Node = Node::Seq(MULTIPLICATIVE_NODES);
// =================================================================
static SIGN_CHOICES: &[Node] = &[Node::Punct('-'), Node::Punct('+')];
static UNARY_SIGN_NODES: &[Node] = &[
Node::Choice(SIGN_CHOICES),
Node::Subgrammar(&UNARY),
];
static UNARY_CHOICES: &[Node] = &[
Node::Seq(UNARY_SIGN_NODES),
Node::Subgrammar(&PRIMARY),
];
static UNARY_SIGN_NODES: &[Node] = &[Node::Choice(SIGN_CHOICES), Node::Subgrammar(&UNARY)];
static UNARY_CHOICES: &[Node] = &[Node::Seq(UNARY_SIGN_NODES), Node::Subgrammar(&PRIMARY)];
static UNARY: Node = Node::Choice(UNARY_CHOICES);
// =================================================================
@@ -402,10 +373,7 @@ static SIMPLE_CASE_NODES: &[Node] = &[
Node::Optional(&ELSE_CLAUSE),
Node::Word(Word::keyword("end")),
];
static CASE_BODY_CHOICES: &[Node] = &[
Node::Seq(SEARCHED_CASE_NODES),
Node::Seq(SIMPLE_CASE_NODES),
];
static CASE_BODY_CHOICES: &[Node] = &[Node::Seq(SEARCHED_CASE_NODES), Node::Seq(SIMPLE_CASE_NODES)];
static CASE_NODES: &[Node] = &[
Node::Word(Word::keyword("case")),
Node::Choice(CASE_BODY_CHOICES),
@@ -467,14 +435,11 @@ const QUALIFIED_REF_IDENT: Node = Node::Ident {
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
static QUALIFIED_REF_TAIL_NODES: &[Node] = &[
Node::Punct('.'),
QUALIFIED_REF_IDENT,
];
static QUALIFIED_REF_TAIL_NODES: &[Node] = &[Node::Punct('.'), QUALIFIED_REF_IDENT];
static NAME_OR_CALL_TAIL_CHOICES: &[Node] = &[
Node::Seq(QUALIFIED_REF_TAIL_NODES),
@@ -531,7 +496,10 @@ mod tests {
/// Assert `input` is *not* a complete SQL expression.
fn bad(input: &str) {
assert!(!walks(input), "{input:?} should NOT walk as a complete expression");
assert!(
!walks(input),
"{input:?} should NOT walk as a complete expression"
);
}
#[test]
@@ -643,13 +611,13 @@ mod tests {
#[test]
fn malformed_expressions_do_not_walk() {
bad("a +"); // dangling operator
bad("a in b"); // IN requires a parenthesised list
bad("= 1"); // no left operand
bad("a = "); // no right operand
bad("case a end"); // CASE with no WHEN clause
bad("and b"); // leading connective
bad("upper("); // unclosed call
bad("a +"); // dangling operator
bad("a in b"); // IN requires a parenthesised list
bad("= 1"); // no left operand
bad("a = "); // no right operand
bad("case a end"); // CASE with no WHEN clause
bad("and b"); // leading connective
bad("upper("); // unclosed call
}
#[test]
@@ -680,9 +648,9 @@ mod tests {
// The optional tail dispatches `.identifier` (qualified
// ref) vs `(args)` (function call) by first token — a
// bare ident remains a column ref.
good("foo(x)"); // function call
good("foo.bar"); // qualified ref
good("foo"); // bare ref
good("foo(x)"); // function call
good("foo.bar"); // qualified ref
good("foo"); // bare ref
}
#[test]
+31 -8
View File
@@ -120,7 +120,10 @@ fn target_value_columns(ctx: &WalkContext) -> Vec<TableColumn> {
listed
.iter()
.filter_map(|name| {
table_cols.iter().find(|c| c.name.eq_ignore_ascii_case(name)).cloned()
table_cols
.iter()
.find(|c| c.name.eq_ignore_ascii_case(name))
.cloned()
})
.collect()
},
@@ -148,7 +151,11 @@ fn target_value_columns(ctx: &WalkContext) -> Vec<TableColumn> {
fn tuple_value_list(ctx: &WalkContext, source: &str, pos: usize) -> Node {
let cols = target_value_columns(ctx);
let (count, closed) = count_tuple_values(source, pos);
let arity_ok = if closed { count == cols.len() } else { count <= cols.len() };
let arity_ok = if closed {
count == cols.len()
} else {
count <= cols.len()
};
if !cols.is_empty() && arity_ok {
Node::DynamicSubgrammar(sql_value_list)
} else {
@@ -304,8 +311,10 @@ static DO_UPDATE_NODES: &[Node] = &[
/// the enclosing Seq, each branch's FIRST token (`nothing` vs
/// `update`) disambiguates, so a non-match of branch 0 is a clean
/// `NoMatch` that falls through to branch 1.
static DO_ACTION_CHOICES: &[Node] =
&[Node::Word(Word::keyword("nothing")), Node::Seq(DO_UPDATE_NODES)];
static DO_ACTION_CHOICES: &[Node] = &[
Node::Word(Word::keyword("nothing")),
Node::Seq(DO_UPDATE_NODES),
];
// `const` — used by value in `ON_CONFLICT_CLAUSE_NODES`.
const DO_ACTION: Node = Node::Choice(DO_ACTION_CHOICES);
@@ -361,7 +370,14 @@ mod tests {
let mut ctx = WalkContext::new();
let mut path = MatchedPath::new();
let mut per_byte = Vec::new();
match walk_node(input, 0, &SQL_INSERT_SHAPE, &mut ctx, &mut path, &mut per_byte) {
match walk_node(
input,
0,
&SQL_INSERT_SHAPE,
&mut ctx,
&mut path,
&mut per_byte,
) {
NodeWalkResult::Matched { end, .. } => input[end..].trim().is_empty(),
_ => false,
}
@@ -372,7 +388,10 @@ mod tests {
}
fn bad(input: &str) {
assert!(!walks(input), "{input:?} should NOT walk as a complete INSERT tail");
assert!(
!walks(input),
"{input:?} should NOT walk as a complete INSERT tail"
);
}
#[test]
@@ -418,8 +437,12 @@ mod tests {
// 3h: ON CONFLICT … DO NOTHING / DO UPDATE (ADR-0033 §9).
good("into t (id, name) values (1, 'x') on conflict (id) do nothing");
good("into t (id, name) values (1, 'x') on conflict do nothing");
good("into t (id, name) values (1, 'x') on conflict (id) do update set name = excluded.name");
good("into t (id, name) values (1, 'x') on conflict (id) do update set name = 'y' where id > 0");
good(
"into t (id, name) values (1, 'x') on conflict (id) do update set name = excluded.name",
);
good(
"into t (id, name) values (1, 'x') on conflict (id) do update set name = 'y' where id > 0",
);
// Multi-column conflict target + multi-assignment DO UPDATE.
good("into t (a, b) values (1, 2) on conflict (a, b) do update set b = excluded.b, a = 9");
// ON CONFLICT composes with RETURNING (order: row source,
+62 -97
View File
@@ -141,8 +141,15 @@ static EMPTY_NOMATCH: Node = Node::Choice(&[]);
/// suffix keywords. `as` is not listed — the AS-form alias is a
/// separate `Choice` branch that fires before the lookahead.
const PROJECTION_FOLLOW_SET: &[&str] = &[
"from", "where", "group", "order", "having", "limit",
"union", "intersect", "except",
"from",
"where",
"group",
"order",
"having",
"limit",
"union",
"intersect",
"except",
// `returning` belongs to an enclosing DML statement
// (`INSERT … SELECT … RETURNING …`, ADR-0033 §5), never to a
// projection item's bare alias — so a no-FROM SELECT row source
@@ -158,9 +165,21 @@ const PROJECTION_FOLLOW_SET: &[&str] = &[
/// only when `b` has no alias — `on` is not a base-table name a
/// learner would type as an alias.
const TABLE_SOURCE_FOLLOW_SET: &[&str] = &[
"where", "group", "order", "having", "limit",
"union", "intersect", "except",
"inner", "left", "right", "full", "cross", "join", "on",
"where",
"group",
"order",
"having",
"limit",
"union",
"intersect",
"except",
"inner",
"left",
"right",
"full",
"cross",
"join",
"on",
// `returning` belongs to an enclosing DML statement
// (`INSERT … SELECT … FROM t RETURNING …`, ADR-0033 §5), so the
// SELECT row source must not read it as table `t`'s bare alias.
@@ -172,15 +191,9 @@ fn peek_next_ident_lower(source: &str, pos: usize) -> Option<String> {
consume_ident(source, p).map(|(s, e)| source[s..e].to_ascii_lowercase())
}
fn projection_bare_alias_factory(
_: &WalkContext,
source: &str,
pos: usize,
) -> Node {
fn projection_bare_alias_factory(_: &WalkContext, source: &str, pos: usize) -> Node {
match peek_next_ident_lower(source, pos) {
Some(word)
if PROJECTION_FOLLOW_SET.iter().any(|k| *k == word) =>
{
Some(word) if PROJECTION_FOLLOW_SET.iter().any(|k| *k == word) => {
Node::Subgrammar(&EMPTY_NOMATCH)
}
Some(_) => PROJECTION_BARE_ALIAS_IDENT,
@@ -188,15 +201,9 @@ fn projection_bare_alias_factory(
}
}
fn table_source_bare_alias_factory(
_: &WalkContext,
source: &str,
pos: usize,
) -> Node {
fn table_source_bare_alias_factory(_: &WalkContext, source: &str, pos: usize) -> Node {
match peek_next_ident_lower(source, pos) {
Some(word)
if TABLE_SOURCE_FOLLOW_SET.iter().any(|k| *k == word) =>
{
Some(word) if TABLE_SOURCE_FOLLOW_SET.iter().any(|k| *k == word) => {
Node::Subgrammar(&EMPTY_NOMATCH)
}
Some(_) => TABLE_SOURCE_BARE_ALIAS_IDENT,
@@ -237,14 +244,12 @@ const TABLE_SOURCE_BARE_ALIAS_IDENT: Node = Node::Ident {
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: true,
writes_cte_name: false,
writes_projection_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
static PROJECTION_AS_ALIAS_NODES: &[Node] = &[
Node::Word(Word::keyword("as")),
PROJECTION_BARE_ALIAS_IDENT,
];
static PROJECTION_AS_ALIAS_NODES: &[Node] =
&[Node::Word(Word::keyword("as")), PROJECTION_BARE_ALIAS_IDENT];
static PROJECTION_AS_ALIAS: Node = Node::Seq(PROJECTION_AS_ALIAS_NODES);
static TABLE_SOURCE_AS_ALIAS_NODES: &[Node] = &[
@@ -258,17 +263,14 @@ static PROJECTION_ALIAS_CHOICES: &[Node] = &[
Node::Lookahead(projection_bare_alias_factory),
];
static PROJECTION_ALIAS_CHOICE: Node = Node::Choice(PROJECTION_ALIAS_CHOICES);
static PROJECTION_ALIAS_OPTIONAL: Node =
Node::Optional(&PROJECTION_ALIAS_CHOICE);
static PROJECTION_ALIAS_OPTIONAL: Node = Node::Optional(&PROJECTION_ALIAS_CHOICE);
static TABLE_SOURCE_ALIAS_CHOICES: &[Node] = &[
Node::Subgrammar(&TABLE_SOURCE_AS_ALIAS),
Node::Lookahead(table_source_bare_alias_factory),
];
static TABLE_SOURCE_ALIAS_CHOICE: Node =
Node::Choice(TABLE_SOURCE_ALIAS_CHOICES);
static TABLE_SOURCE_ALIAS_OPTIONAL: Node =
Node::Optional(&TABLE_SOURCE_ALIAS_CHOICE);
static TABLE_SOURCE_ALIAS_CHOICE: Node = Node::Choice(TABLE_SOURCE_ALIAS_CHOICES);
static TABLE_SOURCE_ALIAS_OPTIONAL: Node = Node::Optional(&TABLE_SOURCE_ALIAS_CHOICE);
// =================================================================
// Projection item
@@ -282,16 +284,13 @@ const QUALIFIED_STAR_QUALIFIER: Node = Node::Ident {
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
static QUALIFIED_STAR_NODES: &[Node] = &[
QUALIFIED_STAR_QUALIFIER,
Node::Punct('.'),
Node::Punct('*'),
];
static QUALIFIED_STAR_NODES: &[Node] =
&[QUALIFIED_STAR_QUALIFIER, Node::Punct('.'), Node::Punct('*')];
static QUALIFIED_STAR: Node = Node::Seq(QUALIFIED_STAR_NODES);
static PROJECTION_EXPR_ITEM_NODES: &[Node] = &[
@@ -310,11 +309,7 @@ static PROJECTION_EXPR_ITEM: Node = Node::Seq(PROJECTION_EXPR_ITEM_NODES);
/// ambiguity between `t.*` and `sql_expr` (which can match a
/// bare `t`), since the walker's `Choice` doesn't backtrack on
/// a committed match.
fn projection_item_factory(
_: &WalkContext,
source: &str,
pos: usize,
) -> Node {
fn projection_item_factory(_: &WalkContext, source: &str, pos: usize) -> Node {
let p = skip_whitespace(source, pos);
let bytes = source.as_bytes();
if bytes.get(p) == Some(&b'*') {
@@ -363,8 +358,7 @@ static DISTINCT_OR_ALL_CHOICES: &[Node] = &[
Node::Word(Word::keyword("all")),
];
static DISTINCT_OR_ALL_CHOICE: Node = Node::Choice(DISTINCT_OR_ALL_CHOICES);
static DISTINCT_OR_ALL_OPTIONAL: Node =
Node::Optional(&DISTINCT_OR_ALL_CHOICE);
static DISTINCT_OR_ALL_OPTIONAL: Node = Node::Optional(&DISTINCT_OR_ALL_CHOICE);
// =================================================================
// Table source (FROM / JOIN target)
@@ -379,8 +373,8 @@ const TABLE_NAME_IDENT: Node = Node::Ident {
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
static TABLE_SOURCE_NODES: &[Node] = &[
@@ -395,8 +389,7 @@ static TABLE_SOURCE: Node = Node::Seq(TABLE_SOURCE_NODES);
const JOIN_WORD: Node = Node::Word(Word::keyword("join"));
const ON_WORD: Node = Node::Word(Word::keyword("on"));
static OUTER_OPTIONAL: Node =
Node::Optional(&Node::Word(Word::keyword("outer")));
static OUTER_OPTIONAL: Node = Node::Optional(&Node::Word(Word::keyword("outer")));
// `INNER JOIN` and bare `JOIN` are split into two Choice
// branches so each branch has a distinct leading keyword
@@ -585,8 +578,7 @@ static SET_OP_CHOICES: &[Node] = &[
];
static SET_OP: Node = Node::Choice(SET_OP_CHOICES);
static SET_OP_TAIL_NODES: &[Node] =
&[Node::Subgrammar(&SET_OP), Node::Subgrammar(&SELECT_CORE)];
static SET_OP_TAIL_NODES: &[Node] = &[Node::Subgrammar(&SET_OP), Node::Subgrammar(&SELECT_CORE)];
static SET_OP_TAIL: Node = Node::Seq(SET_OP_TAIL_NODES);
static PLAIN_COMPOUND_NODES: &[Node] = &[
@@ -619,8 +611,7 @@ static WITH_PREFIXED_COMPOUND_NODES: &[Node] = &[
Node::Subgrammar(&WITH_CLAUSE),
Node::Subgrammar(&PLAIN_COMPOUND),
];
static WITH_PREFIXED_COMPOUND: Node =
Node::Seq(WITH_PREFIXED_COMPOUND_NODES);
static WITH_PREFIXED_COMPOUND: Node = Node::Seq(WITH_PREFIXED_COMPOUND_NODES);
static COMPOUND_CHOICES: &[Node] = &[
Node::Subgrammar(&WITH_PREFIXED_COMPOUND),
@@ -659,9 +650,9 @@ const CTE_COLUMN_IDENT: Node = Node::Ident {
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
static CTE_COLUMN_LIST_NODES: &[Node] = &[
@@ -674,18 +665,13 @@ static CTE_COLUMN_LIST_NODES: &[Node] = &[
RPAREN,
];
static CTE_COLUMN_LIST_SEQ: Node = Node::Seq(CTE_COLUMN_LIST_NODES);
static CTE_COLUMN_LIST_OPTIONAL: Node =
Node::Optional(&CTE_COLUMN_LIST_SEQ);
static CTE_COLUMN_LIST_OPTIONAL: Node = Node::Optional(&CTE_COLUMN_LIST_SEQ);
// CTE body recursion pushes a fresh lexical scope frame (ADR-
// 0032 §4 / §10.2). Subqueries in `sql_expr.rs` do the same;
// the top-level statement's own COMPOUND embedding does not
// (it shares the implicit bottom frame).
static CTE_BODY_NODES: &[Node] = &[
LPAREN,
Node::ScopedSubgrammar(&SQL_SELECT_COMPOUND),
RPAREN,
];
static CTE_BODY_NODES: &[Node] = &[LPAREN, Node::ScopedSubgrammar(&SQL_SELECT_COMPOUND), RPAREN];
static CTE_BODY: Node = Node::Seq(CTE_BODY_NODES);
static CTE_DEF_NODES: &[Node] = &[
@@ -807,9 +793,7 @@ mod tests {
let mut path = MatchedPath::new();
let mut per_byte = Vec::new();
match walk_node(input, 0, fragment, &mut ctx, &mut path, &mut per_byte) {
NodeWalkResult::Matched { end, .. } => {
input[end..].trim().is_empty()
}
NodeWalkResult::Matched { end, .. } => input[end..].trim().is_empty(),
_ => false,
}
}
@@ -819,10 +803,7 @@ mod tests {
}
fn good(input: &str) {
assert!(
walks(input),
"{input:?} should be a valid SELECT statement"
);
assert!(walks(input), "{input:?} should be a valid SELECT statement");
}
fn bad(input: &str) {
@@ -1051,16 +1032,12 @@ mod tests {
#[test]
fn set_op_chain() {
good(
"select a from t union select b from u intersect select c from v",
);
good("select a from t union select b from u intersect select c from v");
}
#[test]
fn set_op_with_outer_order_by_and_limit() {
good(
"select a from t union select b from u order by a limit 10",
);
good("select a from t union select b from u order by a limit 10");
}
// ----- ORDER BY / LIMIT / OFFSET -----
@@ -1126,16 +1103,12 @@ mod tests {
#[test]
fn recursive_cte() {
good(
"with recursive r as (select 1 union all select 2) select * from r",
);
good("with recursive r as (select 1 union all select 2) select * from r");
}
#[test]
fn multiple_ctes() {
good(
"with a as (select 1), b as (select 2) select * from a union select * from b",
);
good("with a as (select 1), b as (select 2) select * from a union select * from b");
}
// ----- subquery shapes (recursion through SQL_SELECT_COMPOUND) -----
@@ -1147,9 +1120,7 @@ mod tests {
#[test]
fn nested_cte_body_with_union() {
good(
"with x as (select 1 union select 2) select * from x",
);
good("with x as (select 1 union select 2) select * from x");
}
// ----- case insensitivity / spacing -----
@@ -1363,9 +1334,7 @@ mod tests {
#[test]
fn in_subquery_in_where_clause() {
good("select * from t where id in (select user_id from orders)");
good(
"select * from customers where id not in (select customer_id from blocklist)",
);
good("select * from customers where id not in (select customer_id from blocklist)");
}
#[test]
@@ -1378,9 +1347,7 @@ mod tests {
#[test]
fn nested_subqueries() {
good(
"select * from t where x in (select y from u where y in (select z from v))",
);
good("select * from t where x in (select y from u where y in (select z from v))");
}
#[test]
@@ -1393,8 +1360,6 @@ mod tests {
#[test]
fn cte_body_references_qualified_columns() {
good(
"with x as (select t.name, t.age from t) select x.name from x",
);
good("with x as (select t.name, t.age from t) select x.name from x");
}
}
+12 -2
View File
@@ -119,7 +119,14 @@ mod tests {
let mut ctx = WalkContext::new();
let mut path = MatchedPath::new();
let mut per_byte = Vec::new();
match walk_node(input, 0, &SQL_UPDATE_SHAPE, &mut ctx, &mut path, &mut per_byte) {
match walk_node(
input,
0,
&SQL_UPDATE_SHAPE,
&mut ctx,
&mut path,
&mut per_byte,
) {
NodeWalkResult::Matched { end, .. } => input[end..].trim().is_empty(),
_ => false,
}
@@ -130,7 +137,10 @@ mod tests {
}
fn bad(input: &str) {
assert!(!walks(input), "{input:?} should NOT walk as a complete UPDATE tail");
assert!(
!walks(input),
"{input:?} should NOT walk as a complete UPDATE tail"
);
}
#[test]
+3 -3
View File
@@ -21,9 +21,9 @@ pub mod walker;
pub use action::ReferentialAction;
pub use command::{
AlterTableAction, AppCommand, ChangeColumnMode, ColumnSpec, Command, CompareOp, CopyScope, Expr,
IndexSelector, MessagesValue, ModeValue, Operand, Predicate, RelationshipSelector, RowFilter,
ShowListKind, SqlForeignKey,
AlterTableAction, AppCommand, ChangeColumnMode, ColumnSpec, Command, CompareOp, CopyScope,
Expr, IndexSelector, MessagesValue, ModeValue, Operand, Predicate, RelationshipSelector,
RowFilter, ShowListKind, SqlForeignKey,
};
pub use parser::{ParseError, parse_command};
pub use types::Type;
+18 -25
View File
@@ -55,10 +55,9 @@ pub enum ParseError {
impl std::fmt::Display for ParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Invalid { message, .. } => f.write_str(&crate::t!(
"parse.error_wrapper",
detail = message,
)),
Self::Invalid { message, .. } => {
f.write_str(&crate::t!("parse.error_wrapper", detail = message,))
}
Self::Empty => f.write_str(&crate::t!("parse.empty")),
}
}
@@ -125,10 +124,7 @@ pub fn parse_command_with_schema(
/// Schemaless, mode-aware parse (ADR-0030 §2). In `Mode::Simple`
/// the walker gates SQL-only commands and produces the
/// "this is SQL" hint instead of executing them.
pub fn parse_command_in_mode(
input: &str,
mode: Mode,
) -> Result<Command, ParseError> {
pub fn parse_command_in_mode(input: &str, mode: Mode) -> Result<Command, ParseError> {
parse_command_inner(input, None, mode)
}
@@ -185,10 +181,8 @@ fn unknown_command_error(source: &str) -> ParseError {
.collect();
let joined = oxford_join(&entries);
let start = skip_whitespace(source, 0);
let (position, found_word) = consume_ident(source, start).map_or_else(
|| (start, None),
|(s, e)| (s, Some(&source[s..e])),
);
let (position, found_word) = consume_ident(source, start)
.map_or_else(|| (start, None), |(s, e)| (s, Some(&source[s..e])));
let message = found_word.map_or_else(
|| format!("expected one of {joined}"),
|w| format!("expected one of {joined}, found `{w}`"),
@@ -1034,19 +1028,22 @@ mod tests {
false,
);
assert_eq!(
ok("add 1:n relationship from Customers.Id to Orders.CustId on delete cascade on update set null"),
ok(
"add 1:n relationship from Customers.Id to Orders.CustId on delete cascade on update set null"
),
expected
);
assert_eq!(
ok("add 1:n relationship from Customers.Id to Orders.CustId on update set null on delete cascade"),
ok(
"add 1:n relationship from Customers.Id to Orders.CustId on update set null on delete cascade"
),
expected
);
}
#[test]
fn add_relationship_repeated_clause_errors() {
let e =
err("add 1:n relationship from C.id to O.cid on delete cascade on delete restrict");
let e = err("add 1:n relationship from C.id to O.cid on delete cascade on delete restrict");
match e {
ParseError::Invalid { message, .. } => {
assert!(message.contains("specified twice"), "{message}");
@@ -1073,7 +1070,9 @@ mod tests {
#[test]
fn add_relationship_with_name_actions_and_flag() {
assert_eq!(
ok("add 1:n relationship as cust_orders from Customers.Id to Orders.CustId on delete cascade on update no action --create-fk"),
ok(
"add 1:n relationship as cust_orders from Customers.Id to Orders.CustId on delete cascade on update no action --create-fk"
),
rel(
Some("cust_orders"),
("Customers", "Id"),
@@ -1300,10 +1299,7 @@ mod tests {
#[test]
fn advanced_ambiguous_update_routes_to_sql() {
assert!(matches!(
parse_command_in_mode(
"update Orders set total = 0 where id = 1",
Mode::Advanced,
),
parse_command_in_mode("update Orders set total = 0 where id = 1", Mode::Advanced,),
Ok(Command::SqlUpdate { .. })
));
}
@@ -1399,10 +1395,7 @@ mod tests {
// in advanced mode)" pointer is added at the hint layer
// (input_render), not in the parsed command/error here.
assert!(matches!(
parse_command_in_mode(
"delete from Orders where id = 1 returning *",
Mode::Simple,
),
parse_command_in_mode("delete from Orders where id = 1 returning *", Mode::Simple,),
Err(ParseError::Invalid { .. })
));
}
+1 -2
View File
@@ -9,8 +9,7 @@ use rand::RngExt;
/// Base58 alphabet — Bitcoin-style. 0 / O / I / l are excluded
/// because they are easily confused in print.
const ALPHABET: &[u8; 58] =
b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
const ALPHABET: &[u8; 58] = b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
const DEFAULT_LEN: usize = 10;
+4 -26
View File
@@ -43,29 +43,9 @@
/// - **Broader scalars:** `date`, `datetime`, `hex`, `ifnull`,
/// `instr`, `nullif`, `random`, `replace`, `strftime`, `typeof`.
pub const KNOWN_SQL_FUNCTIONS: &[&str] = &[
"abs",
"avg",
"coalesce",
"count",
"date",
"datetime",
"hex",
"ifnull",
"instr",
"length",
"lower",
"max",
"min",
"nullif",
"random",
"replace",
"round",
"strftime",
"substr",
"sum",
"trim",
"typeof",
"upper",
"abs", "avg", "coalesce", "count", "date", "datetime", "hex", "ifnull", "instr", "length",
"lower", "max", "min", "nullif", "random", "replace", "round", "strftime", "substr", "sum",
"trim", "typeof", "upper",
];
/// Whether `partial` is a case-insensitive prefix of at least one
@@ -80,9 +60,7 @@ pub const KNOWN_SQL_FUNCTIONS: &[&str] = &[
#[must_use]
pub fn is_known_function_prefix(partial: &str) -> bool {
let lowered = partial.to_lowercase();
KNOWN_SQL_FUNCTIONS
.iter()
.any(|f| f.starts_with(&lowered))
KNOWN_SQL_FUNCTIONS.iter().any(|f| f.starts_with(&lowered))
}
#[cfg(test)]
+2 -9
View File
@@ -59,11 +59,7 @@ impl Type {
#[must_use]
pub const fn sqlite_strict_type(self) -> &'static str {
match self {
Self::Text
| Self::ShortId
| Self::Decimal
| Self::Date
| Self::DateTime => "TEXT",
Self::Text | Self::ShortId | Self::Decimal | Self::Date | Self::DateTime => "TEXT",
Self::Int | Self::Serial | Self::Bool => "INTEGER",
Self::Real => "REAL",
Self::Blob => "BLOB",
@@ -107,10 +103,7 @@ impl Type {
/// match against a numeric column (ADR-0027, Amendment 1).
#[must_use]
pub const fn is_numeric(self) -> bool {
matches!(
self,
Self::Int | Self::Real | Self::Decimal | Self::Serial
)
matches!(self, Self::Int | Self::Real | Self::Decimal | Self::Serial)
}
/// The user-facing type that an FK column should use to
+37 -18
View File
@@ -129,13 +129,14 @@ impl Value {
fn bind_int(&self, column: &str, ty: Type) -> Result<Bound, ValueError> {
match self {
Self::Number(n) => n
.parse::<i64>()
.map(Bound::Integer)
.map_err(|_| ValueError::Format {
column: column.to_string(),
message: format!("`{n}` is not a valid {ty} (whole number expected)"),
}),
Self::Number(n) => {
n.parse::<i64>()
.map(Bound::Integer)
.map_err(|_| ValueError::Format {
column: column.to_string(),
message: format!("`{n}` is not a valid {ty} (whole number expected)"),
})
}
other => Err(ValueError::TypeMismatch {
column: column.to_string(),
expected_human: format!("a whole number for `{ty}`"),
@@ -241,9 +242,7 @@ pub(crate) fn validate_date(s: &str) -> Result<(), String> {
// Expect YYYY-MM-DD: 10 chars, two dashes at fixed positions.
let bytes = s.as_bytes();
if bytes.len() != 10 || bytes[4] != b'-' || bytes[7] != b'-' {
return Err(format!(
"`{s}` is not a date in `YYYY-MM-DD` form"
));
return Err(format!("`{s}` is not a date in `YYYY-MM-DD` form"));
}
let year = parse_digits(&s[0..4]).ok_or_else(|| format!("`{s}`: invalid year"))?;
let month = parse_digits(&s[5..7]).ok_or_else(|| format!("`{s}`: invalid month"))?;
@@ -272,7 +271,9 @@ pub(crate) fn validate_datetime(s: &str) -> Result<(), String> {
validate_date(date_part)?;
let bytes = s.as_bytes();
if bytes[10] != b'T' {
return Err(format!("`{s}`: missing `T` separator between date and time"));
return Err(format!(
"`{s}`: missing `T` separator between date and time"
));
}
if bytes[13] != b':' || bytes[16] != b':' {
return Err(format!("`{s}`: time portion must be `HH:MM:SS`"));
@@ -326,8 +327,14 @@ mod tests {
#[test]
fn integer_for_int_column() {
assert_eq!(n("42").bind_for_column("c", Type::Int).unwrap(), Bound::Integer(42));
assert_eq!(n("-7").bind_for_column("c", Type::Int).unwrap(), Bound::Integer(-7));
assert_eq!(
n("42").bind_for_column("c", Type::Int).unwrap(),
Bound::Integer(42)
);
assert_eq!(
n("-7").bind_for_column("c", Type::Int).unwrap(),
Bound::Integer(-7)
);
}
#[test]
@@ -355,7 +362,9 @@ mod tests {
#[test]
fn shortid_validation_runs_on_text_for_shortid_column() {
let err = t("toolong_xyz_more").bind_for_column("c", Type::ShortId).unwrap_err();
let err = t("toolong_xyz_more")
.bind_for_column("c", Type::ShortId)
.unwrap_err();
assert!(matches!(err, ValueError::Format { .. }));
// Well-formed shortid binds fine.
@@ -367,8 +376,14 @@ mod tests {
#[test]
fn bool_for_bool_column_maps_to_zero_or_one() {
assert_eq!(Value::Bool(true).bind_for_column("c", Type::Bool).unwrap(), Bound::Integer(1));
assert_eq!(Value::Bool(false).bind_for_column("c", Type::Bool).unwrap(), Bound::Integer(0));
assert_eq!(
Value::Bool(true).bind_for_column("c", Type::Bool).unwrap(),
Bound::Integer(1)
);
assert_eq!(
Value::Bool(false).bind_for_column("c", Type::Bool).unwrap(),
Bound::Integer(0)
);
}
#[test]
@@ -377,13 +392,17 @@ mod tests {
t("2025-01-15").bind_for_column("c", Type::Date).unwrap(),
Bound::Text("2025-01-15".to_string())
);
let err = t("2025/01/15").bind_for_column("c", Type::Date).unwrap_err();
let err = t("2025/01/15")
.bind_for_column("c", Type::Date)
.unwrap_err();
assert!(matches!(err, ValueError::Format { .. }));
}
#[test]
fn date_range_check() {
let err = t("2025-13-01").bind_for_column("c", Type::Date).unwrap_err();
let err = t("2025-13-01")
.bind_for_column("c", Type::Date)
.unwrap_err();
assert!(matches!(err, ValueError::Format { message, .. } if message.contains("month")));
}
+119 -179
View File
@@ -28,12 +28,10 @@ use crate::completion::TableColumn;
use crate::dsl::grammar::{HighlightClass, Node, ValidationError};
use crate::dsl::walker::context::WalkContext;
use crate::dsl::walker::lex_helpers::{
consume_bare_path, consume_flag, consume_ident, consume_number_literal,
consume_string_literal, skip_whitespace,
};
use crate::dsl::walker::outcome::{
ByteClass, Expectation, MatchedItem, MatchedKind, MatchedPath,
consume_bare_path, consume_flag, consume_ident, consume_number_literal, consume_string_literal,
skip_whitespace,
};
use crate::dsl::walker::outcome::{ByteClass, Expectation, MatchedItem, MatchedKind, MatchedPath};
/// Maximum nesting of `Node::Subgrammar` frames (ADR-0026 §1).
///
@@ -77,10 +75,7 @@ static DYNAMIC_CACHE: LazyLock<Mutex<HashMap<DynamicKey, &'static Node>>> =
/// Resolve a `DynamicSubgrammar` factory to a `&'static Node`,
/// reusing a previously-leaked Node when the factory's inputs
/// match a cached entry.
fn resolve_dynamic(
factory: fn(&WalkContext) -> Node,
ctx: &WalkContext,
) -> &'static Node {
fn resolve_dynamic(factory: fn(&WalkContext) -> Node, ctx: &WalkContext) -> &'static Node {
let key = DynamicKey {
factory: factory as usize,
current_table_columns: ctx.current_table_columns.clone(),
@@ -123,10 +118,7 @@ pub enum NodeWalkResult {
expected: Vec<Expectation>,
},
/// Committed and hit a hard mismatch or validator failure.
Failed {
position: usize,
kind: FailureKind,
},
Failed { position: usize, kind: FailureKind },
}
const fn matched(end: usize) -> NodeWalkResult {
@@ -218,9 +210,7 @@ fn walk_node_inner(
kind: FailureKind::Mismatch { expected: vec![] },
}
}
Node::Subgrammar(inner) => {
walk_subgrammar(source, pos, inner, ctx, path, per_byte)
}
Node::Subgrammar(inner) => walk_subgrammar(source, pos, inner, ctx, path, per_byte),
Node::ScopedSubgrammar(inner) => {
walk_scoped_subgrammar(source, pos, inner, ctx, path, per_byte)
}
@@ -247,8 +237,7 @@ fn walk_node_inner(
// DynamicSubgrammar wrapper that delegates to the
// memoized `column_value_list`), so the per-walk
// leak is a few bytes, not a whole typed tree.
let resolved: &'static Node =
Box::leak(Box::new(factory(ctx, source, pos)));
let resolved: &'static Node = Box::leak(Box::new(factory(ctx, source, pos)));
walk_node(source, pos, resolved, ctx, path, per_byte)
}
Node::SetColumn(col) => {
@@ -262,7 +251,10 @@ fn walk_node_inner(
let col: &crate::completion::TableColumn = col;
ctx.current_column = Some(col.clone());
ctx.pending_value_column = Some(col.name.clone());
NodeWalkResult::Matched { end: pos, skipped: Vec::new() }
NodeWalkResult::Matched {
end: pos,
skipped: Vec::new(),
}
}
Node::TypedValueSlot {
ty,
@@ -342,7 +334,10 @@ fn walk_word(
// Amendment 4). Plain keywords leave it `None`.
class: word.highlight_override.unwrap_or(HighlightClass::Keyword),
});
NodeWalkResult::Matched { end, skipped: Vec::new() }
NodeWalkResult::Matched {
end,
skipped: Vec::new(),
}
} else {
NodeWalkResult::NoMatch {
position,
@@ -477,9 +472,7 @@ fn walk_ident(
// ScopedSubgrammar (which is structurally guaranteed to be
// the CTE body — no intervening scoped subgrammar in CTE
// syntax) runs the harvest at body-frame exit.
if writes_cte_name
&& let Some(frame) = ctx.from_scope_stack.last_mut()
{
if writes_cte_name && let Some(frame) = ctx.from_scope_stack.last_mut() {
frame
.cte_bindings
.push(crate::dsl::walker::context::CteBinding {
@@ -487,13 +480,12 @@ fn walk_ident(
columns: Vec::new(),
});
let placeholder_index = frame.cte_bindings.len() - 1;
ctx.pending_cte_harvest =
Some(crate::dsl::walker::context::PendingCteHarvest {
placeholder_index,
col_list: Vec::new(),
cte_name: text.clone(),
cte_name_span: (start, end),
});
ctx.pending_cte_harvest = Some(crate::dsl::walker::context::PendingCteHarvest {
placeholder_index,
col_list: Vec::new(),
cte_name: text.clone(),
cte_name_span: (start, end),
});
}
// ADR-0032 §10.3: the optional `(c1, c2, …)` rename list
// between the cte name and `AS`. Each `cte_column` ident
@@ -507,9 +499,7 @@ fn walk_ident(
}
// ADR-0032 §10.4: projection-list alias accumulator for
// ORDER BY completion candidates.
if writes_projection_alias
&& let Some(frame) = ctx.from_scope_stack.last_mut()
{
if writes_projection_alias && let Some(frame) = ctx.from_scope_stack.last_mut() {
frame.projection_aliases.push(text.clone());
}
if writes_column && matches!(src, crate::dsl::grammar::IdentSource::Columns) {
@@ -529,9 +519,7 @@ fn walk_ident(
.map(|c| c.name.clone())
.or_else(|| Some(text.clone()));
}
if writes_user_listed_column
&& matches!(src, crate::dsl::grammar::IdentSource::Columns)
{
if writes_user_listed_column && matches!(src, crate::dsl::grammar::IdentSource::Columns) {
// Form A: `insert into <T> (col1, col2, …)`. Append the
// matched column name to user_listed_columns so the
// inner `values (…)` slot list mirrors the user's
@@ -564,7 +552,10 @@ fn walk_ident(
// (issue #8 / ADR-0022 Amendment 4).
class: highlight_override.unwrap_or(HighlightClass::Identifier),
});
NodeWalkResult::Matched { end, skipped: Vec::new() }
NodeWalkResult::Matched {
end,
skipped: Vec::new(),
}
}
fn walk_string_lit(
@@ -648,7 +639,10 @@ fn walk_literal(
end,
class,
});
NodeWalkResult::Matched { end, skipped: Vec::new() }
NodeWalkResult::Matched {
end,
skipped: Vec::new(),
}
}
fn walk_number_lit(
@@ -683,7 +677,10 @@ fn walk_number_lit(
end,
class: HighlightClass::Number,
});
NodeWalkResult::Matched { end, skipped: Vec::new() }
NodeWalkResult::Matched {
end,
skipped: Vec::new(),
}
}
fn walk_flag(
@@ -717,7 +714,10 @@ fn walk_flag(
end,
class: HighlightClass::Flag,
});
NodeWalkResult::Matched { end, skipped: Vec::new() }
NodeWalkResult::Matched {
end,
skipped: Vec::new(),
}
}
#[allow(clippy::too_many_arguments)]
@@ -784,7 +784,10 @@ fn walk_repeated(
count += 1;
last_item_skipped = skipped;
}
NodeWalkResult::NoMatch { expected, position: inner_pos } => {
NodeWalkResult::NoMatch {
expected,
position: inner_pos,
} => {
// Mid-typing-the-next-item recovery: if the
// separator just consumed and the inner failed
// at EOF, the user is partway through typing the
@@ -860,7 +863,10 @@ fn walk_bare_path(
end,
class: HighlightClass::String,
});
NodeWalkResult::Matched { end, skipped: Vec::new() }
NodeWalkResult::Matched {
end,
skipped: Vec::new(),
}
}
fn walk_choice(
@@ -1031,7 +1037,10 @@ fn walk_optional(
skipped: expected,
}
}
NodeWalkResult::Incomplete { position: p, expected } if !inner_committed => {
NodeWalkResult::Incomplete {
position: p,
expected,
} if !inner_committed => {
// Inner reported Incomplete without consuming
// anything — same as NoMatch from the user's
// perspective. Roll back and skip.
@@ -1156,9 +1165,7 @@ fn walk_scoped_subgrammar(
// walks that NoMatch / Incomplete / Fail leave the placeholder
// empty (the outer-frame state is also discarded in the
// speculative path, so this is correct).
if let (Some(req), NodeWalkResult::Matched { end, .. }) =
(pending_cte, &result)
{
if let (Some(req), NodeWalkResult::Matched { end, .. }) = (pending_cte, &result) {
run_cte_harvest(ctx, path, source, pos, *end, &req);
}
@@ -1240,9 +1247,8 @@ fn run_cte_harvest(
select_idx = Some(i + 1); // start of projection list
}
MatchedKind::Word(
"from" | "where" | "group" | "having" | "order"
| "limit" | "offset" | "union" | "intersect"
| "except",
"from" | "where" | "group" | "having" | "order" | "limit" | "offset" | "union"
| "intersect" | "except",
) if select_idx.is_some() => {
end_idx = i;
break;
@@ -1281,12 +1287,7 @@ fn run_cte_harvest(
// Classify each projection item per ADR-0032 §10.3.
let mut derived: Vec<CteColumn> = Vec::new();
for slice in item_slices {
classify_projection_item(
slice,
body_frame,
&ctx.from_scope_stack,
&mut derived,
);
classify_projection_item(slice, body_frame, &ctx.from_scope_stack, &mut derived);
}
// Apply (c1, c2, …) positional rename if provided. Types
@@ -1339,8 +1340,7 @@ fn run_cte_harvest(
let stack_len = ctx.from_scope_stack.len();
if stack_len >= 2
&& let Some(outer) = ctx.from_scope_stack.get_mut(stack_len - 2)
&& let Some(placeholder) =
outer.cte_bindings.get_mut(req.placeholder_index)
&& let Some(placeholder) = outer.cte_bindings.get_mut(req.placeholder_index)
{
placeholder.columns = derived;
}
@@ -1368,9 +1368,7 @@ fn classify_projection_item(
// empty because it wasn't a base-table lookup), resolve
// through to the in-scope CteBinding so nested CTEs project
// correctly.
if expr_slice.len() == 1
&& matches!(expr_slice[0].kind, MatchedKind::Punct('*'))
{
if expr_slice.len() == 1 && matches!(expr_slice[0].kind, MatchedKind::Punct('*')) {
for binding in &body_frame.from_scope {
for col in expand_binding(binding, scope_stack) {
out.push(col);
@@ -1383,7 +1381,10 @@ fn classify_projection_item(
if expr_slice.len() == 3
&& matches!(
expr_slice[0].kind,
MatchedKind::Ident { role: "qualified_star_qualifier", .. }
MatchedKind::Ident {
role: "qualified_star_qualifier",
..
}
)
&& matches!(expr_slice[1].kind, MatchedKind::Punct('.'))
&& matches!(expr_slice[2].kind, MatchedKind::Punct('*'))
@@ -1413,11 +1414,7 @@ fn classify_projection_item(
)
{
let col_text = &expr_slice[0].text;
let resolved_type = resolve_bare_column_type_in_frame(
body_frame,
scope_stack,
col_text,
);
let resolved_type = resolve_bare_column_type_in_frame(body_frame, scope_stack, col_text);
let name = alias.unwrap_or_else(|| col_text.clone());
out.push(CteColumn {
name: Some(name),
@@ -1447,12 +1444,7 @@ fn classify_projection_item(
{
let qual = &expr_slice[0].text;
let col_text = &expr_slice[2].text;
let resolved_type = resolve_qualified_column_type(
body_frame,
scope_stack,
qual,
col_text,
);
let resolved_type = resolve_qualified_column_type(body_frame, scope_stack, qual, col_text);
let name = alias.unwrap_or_else(|| col_text.clone());
out.push(CteColumn {
name: Some(name),
@@ -1493,16 +1485,8 @@ fn strip_trailing_alias<'a>(
}
) {
// Optional preceding `AS` keyword.
if slice.len() >= 2
&& matches!(
slice[slice.len() - 2].kind,
MatchedKind::Word("as")
)
{
return (
&slice[..slice.len() - 2],
Some(last.text.clone()),
);
if slice.len() >= 2 && matches!(slice[slice.len() - 2].kind, MatchedKind::Word("as")) {
return (&slice[..slice.len() - 2], Some(last.text.clone()));
}
return (&slice[..slice.len() - 1], Some(last.text.clone()));
}
@@ -1613,8 +1597,8 @@ fn merge_expected(dst: &mut Vec<Expectation>, src: Vec<Expectation>) {
#[cfg(test)]
mod tests {
use super::{
DYNAMIC_CACHE, FailureKind, MAX_SUBGRAMMAR_DEPTH, NodeWalkResult,
resolve_dynamic, walk_node,
DYNAMIC_CACHE, FailureKind, MAX_SUBGRAMMAR_DEPTH, NodeWalkResult, resolve_dynamic,
walk_node,
};
use crate::dsl::grammar::{Node, Word};
use crate::dsl::walker::context::WalkContext;
@@ -1629,18 +1613,14 @@ mod tests {
Node::Subgrammar(&NESTED),
Node::Punct(')'),
];
static NESTED_CHOICES: &[Node] = &[
Node::Seq(NESTED_GROUP),
Node::Word(Word::keyword("x")),
];
static NESTED_CHOICES: &[Node] = &[Node::Seq(NESTED_GROUP), Node::Word(Word::keyword("x"))];
static NESTED: Node = Node::Choice(NESTED_CHOICES);
fn walk_nested(input: &str) -> NodeWalkResult {
let mut ctx = WalkContext::new();
let mut path = MatchedPath::new();
let mut per_byte = Vec::new();
let result =
walk_node(input, 0, &NESTED, &mut ctx, &mut path, &mut per_byte);
let result = walk_node(input, 0, &NESTED, &mut ctx, &mut path, &mut per_byte);
assert_eq!(
ctx.subgrammar_depth, 0,
"subgrammar_depth must be restored to 0 after the walk",
@@ -1726,14 +1706,8 @@ mod tests {
fn resolve_dynamic_cache_is_populated() {
let ctx = WalkContext::new();
let _ = resolve_dynamic(const_factory, &ctx);
let populated = !DYNAMIC_CACHE
.lock()
.expect("cache lock")
.is_empty();
assert!(
populated,
"resolve_dynamic should populate the memo cache",
);
let populated = !DYNAMIC_CACHE.lock().expect("cache lock").is_empty();
assert!(populated, "resolve_dynamic should populate the memo cache",);
}
// ---- ScopedSubgrammar (ADR-0032 §10.2) -----------------------
@@ -1758,14 +1732,7 @@ mod tests {
let mut path = MatchedPath::new();
let mut per_byte = Vec::new();
let baseline_frames = ctx.from_scope_stack.len();
let result = walk_node(
input,
0,
&SCOPED_NESTED,
&mut ctx,
&mut path,
&mut per_byte,
);
let result = walk_node(input, 0, &SCOPED_NESTED, &mut ctx, &mut path, &mut per_byte);
assert_eq!(
ctx.subgrammar_depth, 0,
"subgrammar_depth must be restored to 0 after the walk",
@@ -1801,9 +1768,9 @@ mod tests {
kind: FailureKind::Validation(err),
..
} => assert_eq!(err.message_key, "parse.custom.expression_too_deep"),
other => panic!(
"expected expression_too_deep on pathological scoped nesting, got {other:?}",
),
other => {
panic!("expected expression_too_deep on pathological scoped nesting, got {other:?}",)
}
}
}
@@ -1822,9 +1789,7 @@ mod tests {
/// Walk a top-level SQL SELECT and return the bottom frame's
/// `from_scope` after the walk completes. Used to verify that
/// `writes_table` / `writes_table_alias` populate bindings.
fn from_scope_after_walk(
input: &str,
) -> Vec<crate::dsl::walker::context::TableBinding> {
fn from_scope_after_walk(input: &str) -> Vec<crate::dsl::walker::context::TableBinding> {
let mut ctx = WalkContext::new();
let mut path = MatchedPath::new();
let mut per_byte = Vec::new();
@@ -1871,9 +1836,7 @@ mod tests {
#[test]
fn join_pushes_a_second_binding() {
let bindings = from_scope_after_walk(
"select * from a join b on x = y",
);
let bindings = from_scope_after_walk("select * from a join b on x = y");
assert_eq!(bindings.len(), 2);
assert_eq!(bindings[0].table, "a");
assert_eq!(bindings[1].table, "b");
@@ -1881,9 +1844,7 @@ mod tests {
#[test]
fn join_with_aliases() {
let bindings = from_scope_after_walk(
"select * from a as x join b as y on x.id = y.id",
);
let bindings = from_scope_after_walk("select * from a as x join b as y on x.id = y.id");
assert_eq!(bindings.len(), 2);
assert_eq!(bindings[0].table, "a");
assert_eq!(bindings[0].alias, Some("x".to_string()));
@@ -1893,9 +1854,8 @@ mod tests {
#[test]
fn three_way_join_pushes_three_bindings() {
let bindings = from_scope_after_walk(
"select * from a join b on x = y left join c on y = z",
);
let bindings =
from_scope_after_walk("select * from a join b on x = y left join c on y = z");
assert_eq!(bindings.len(), 3);
assert_eq!(bindings[0].table, "a");
assert_eq!(bindings[1].table, "b");
@@ -1908,9 +1868,8 @@ mod tests {
// binding into the inner scope frame; on exit, the frame
// pops and the inner binding is gone. The outer scope's
// from_scope still contains only `outer_t`.
let bindings = from_scope_after_walk(
"select * from outer_t where id in (select id from inner_t)",
);
let bindings =
from_scope_after_walk("select * from outer_t where id in (select id from inner_t)");
assert_eq!(bindings.len(), 1);
assert_eq!(bindings[0].table, "outer_t");
}
@@ -1921,9 +1880,8 @@ mod tests {
// body's scope frame; on body-frame exit, the inner
// binding goes away. The outer scope contains only
// the CTE-name reference `cte_x`.
let bindings = from_scope_after_walk(
"with cte_x as (select * from base_table) select * from cte_x",
);
let bindings =
from_scope_after_walk("with cte_x as (select * from base_table) select * from cte_x");
assert_eq!(bindings.len(), 1);
assert_eq!(bindings[0].table, "cte_x");
}
@@ -1940,10 +1898,7 @@ mod tests {
/// `cte_bindings` and `projection_aliases` after the walk.
fn frame_state_after_walk(
input: &str,
) -> (
Vec<crate::dsl::walker::context::CteBinding>,
Vec<String>,
) {
) -> (Vec<crate::dsl::walker::context::CteBinding>, Vec<String>) {
let mut ctx = WalkContext::new();
let mut path = MatchedPath::new();
let mut per_byte = Vec::new();
@@ -1968,9 +1923,7 @@ mod tests {
#[test]
fn cte_name_pushes_placeholder_binding() {
let (ctes, _) = frame_state_after_walk(
"with cte_x as (select 1) select * from cte_x",
);
let (ctes, _) = frame_state_after_walk("with cte_x as (select 1) select * from cte_x");
assert_eq!(ctes.len(), 1);
assert_eq!(ctes[0].name, "cte_x");
// §10.3 stage-2 harvest produces one CteColumn per
@@ -1984,9 +1937,8 @@ mod tests {
#[test]
fn multiple_ctes_push_in_order() {
let (ctes, _) = frame_state_after_walk(
"with a as (select 1), b as (select 2) select * from b",
);
let (ctes, _) =
frame_state_after_walk("with a as (select 1), b as (select 2) select * from b");
assert_eq!(ctes.len(), 2);
assert_eq!(ctes[0].name, "a");
assert_eq!(ctes[1].name, "b");
@@ -2006,25 +1958,20 @@ mod tests {
#[test]
fn projection_aliases_captured_via_as_form() {
let (_, aliases) = frame_state_after_walk(
"select a as alpha, b as beta from t",
);
let (_, aliases) = frame_state_after_walk("select a as alpha, b as beta from t");
assert_eq!(aliases, vec!["alpha".to_string(), "beta".to_string()]);
}
#[test]
fn projection_aliases_captured_via_bare_form() {
let (_, aliases) = frame_state_after_walk(
"select a alpha, b beta from t",
);
let (_, aliases) = frame_state_after_walk("select a alpha, b beta from t");
assert_eq!(aliases, vec!["alpha".to_string(), "beta".to_string()]);
}
#[test]
fn projection_aliases_mixed_forms() {
let (_, aliases) = frame_state_after_walk(
"select a as alpha, b beta, c, d as delta from t",
);
let (_, aliases) =
frame_state_after_walk("select a as alpha, b beta, c, d as delta from t");
assert_eq!(
aliases,
vec!["alpha".to_string(), "beta".to_string(), "delta".to_string()]
@@ -2033,8 +1980,7 @@ mod tests {
#[test]
fn projection_aliases_empty_when_no_aliases() {
let (_, aliases) =
frame_state_after_walk("select a, b from t");
let (_, aliases) = frame_state_after_walk("select a, b from t");
assert!(aliases.is_empty());
}
@@ -2088,9 +2034,24 @@ mod tests {
s.table_columns.insert(
"users".to_string(),
vec![
TableColumn { name: "id".to_string(), user_type: Type::Int, not_null: false, has_default: false },
TableColumn { name: "name".to_string(), user_type: Type::Text, not_null: false, has_default: false },
TableColumn { name: "age".to_string(), user_type: Type::Int, not_null: false, has_default: false },
TableColumn {
name: "id".to_string(),
user_type: Type::Int,
not_null: false,
has_default: false,
},
TableColumn {
name: "name".to_string(),
user_type: Type::Text,
not_null: false,
has_default: false,
},
TableColumn {
name: "age".to_string(),
user_type: Type::Int,
not_null: false,
has_default: false,
},
],
);
s
@@ -2108,10 +2069,7 @@ mod tests {
assert_eq!(ctes.len(), 1);
assert_eq!(ctes[0].columns.len(), 3);
assert_eq!(ctes[0].columns[0].name.as_deref(), Some("id"));
assert_eq!(
ctes[0].columns[0].type_,
Some(crate::dsl::types::Type::Int),
);
assert_eq!(ctes[0].columns[0].type_, Some(crate::dsl::types::Type::Int),);
assert_eq!(ctes[0].columns[1].name.as_deref(), Some("name"));
assert_eq!(
ctes[0].columns[1].type_,
@@ -2161,10 +2119,7 @@ mod tests {
);
assert_eq!(ctes[0].columns.len(), 1);
assert_eq!(ctes[0].columns[0].name.as_deref(), Some("age"));
assert_eq!(
ctes[0].columns[0].type_,
Some(crate::dsl::types::Type::Int),
);
assert_eq!(ctes[0].columns[0].type_, Some(crate::dsl::types::Type::Int),);
}
#[test]
@@ -2259,15 +2214,9 @@ mod tests {
.expect("outer_cte binding");
assert_eq!(outer.columns.len(), 2);
assert_eq!(outer.columns[0].name.as_deref(), Some("id"));
assert_eq!(
outer.columns[0].type_,
Some(crate::dsl::types::Type::Int),
);
assert_eq!(outer.columns[0].type_, Some(crate::dsl::types::Type::Int),);
assert_eq!(outer.columns[1].name.as_deref(), Some("name"));
assert_eq!(
outer.columns[1].type_,
Some(crate::dsl::types::Type::Text),
);
assert_eq!(outer.columns[1].type_, Some(crate::dsl::types::Type::Text),);
}
#[test]
@@ -2287,15 +2236,9 @@ mod tests {
let b = ctes.iter().find(|c| c.name == "b").expect("b binding");
assert_eq!(b.columns.len(), 2);
assert_eq!(b.columns[0].name.as_deref(), Some("id"));
assert_eq!(
b.columns[0].type_,
Some(crate::dsl::types::Type::Int),
);
assert_eq!(b.columns[0].type_, Some(crate::dsl::types::Type::Int),);
assert_eq!(b.columns[1].name.as_deref(), Some("name"));
assert_eq!(
b.columns[1].type_,
Some(crate::dsl::types::Type::Text),
);
assert_eq!(b.columns[1].type_, Some(crate::dsl::types::Type::Text),);
}
#[test]
@@ -2310,10 +2253,7 @@ mod tests {
);
assert_eq!(ctes[0].columns.len(), 3);
assert_eq!(ctes[0].columns[0].name.as_deref(), Some("a"));
assert_eq!(
ctes[0].columns[0].type_,
Some(crate::dsl::types::Type::Int),
);
assert_eq!(ctes[0].columns[0].type_, Some(crate::dsl::types::Type::Int),);
assert_eq!(ctes[0].columns[1].name.as_deref(), Some("b"));
assert_eq!(
ctes[0].columns[1].type_,
+41 -28
View File
@@ -24,8 +24,8 @@
use crate::dsl::grammar::HighlightClass;
use crate::dsl::walker::context::WalkContext;
use crate::dsl::walker::lex_helpers::{
consume_bare_path, consume_flag, consume_ident, consume_number_literal,
consume_string_literal, skip_whitespace,
consume_bare_path, consume_flag, consume_ident, consume_number_literal, consume_string_literal,
skip_whitespace,
};
use crate::dsl::walker::outcome::{ByteClass, WalkBound};
@@ -47,16 +47,11 @@ pub fn highlight_runs(source: &str) -> Vec<ByteClass> {
/// token, producing the keyword classes the renderer needs to
/// colour `select` / `from` / `where` / `union` / `case` / etc.
#[must_use]
pub fn highlight_runs_in_mode(
source: &str,
mode: crate::mode::Mode,
) -> Vec<ByteClass> {
pub fn highlight_runs_in_mode(source: &str, mode: crate::mode::Mode) -> Vec<ByteClass> {
let mut ctx = WalkContext::new();
ctx.mode = mode;
let (result, _cmd) = super::walk(source, WalkBound::EndOfInput, &mut ctx);
let mut classes: Vec<ByteClass> = result
.map(|r| r.per_byte_class)
.unwrap_or_default();
let mut classes: Vec<ByteClass> = result.map(|r| r.per_byte_class).unwrap_or_default();
let scan_start = classes.last().map_or(0, |c| c.end);
scan_remainder(source, scan_start, &mut classes);
@@ -133,9 +128,7 @@ fn scan_remainder(source: &str, start: usize, classes: &mut Vec<ByteClass>) {
.get(pos + 1)
.copied()
.is_some_and(|c| c.is_ascii_digit()));
if looks_like_number
&& let Some((s, e)) = consume_number_literal(source, pos)
{
if looks_like_number && let Some((s, e)) = consume_number_literal(source, pos) {
classes.push(ByteClass {
start: s,
end: e,
@@ -222,8 +215,14 @@ mod tests {
"no Error highlight on a valid m:n line: {runs:?}"
);
let kinds: Vec<HighlightClass> = runs.iter().map(|(_, _, c)| *c).collect();
assert!(kinds.contains(&HighlightClass::Keyword), "keywords highlighted: {runs:?}");
assert!(kinds.contains(&HighlightClass::Identifier), "table names highlighted: {runs:?}");
assert!(
kinds.contains(&HighlightClass::Keyword),
"keywords highlighted: {runs:?}"
);
assert!(
kinds.contains(&HighlightClass::Identifier),
"table names highlighted: {runs:?}"
);
}
#[test]
@@ -276,10 +275,7 @@ mod tests {
#[test]
fn flag_classified_via_fallback() {
// Walker doesn't engage for a bare `--all-rows`.
assert_eq!(
run("--all-rows"),
vec![(0, 10, HighlightClass::Flag)],
);
assert_eq!(run("--all-rows"), vec![(0, 10, HighlightClass::Flag)],);
}
#[test]
@@ -445,15 +441,13 @@ mod tests {
// dispatcher, so only the entry word would highlight).
let runs = run_advanced("select * from t");
assert!(
runs.iter().any(|(s, e, c)| {
*c == HighlightClass::Keyword && (*s, *e) == (0, 6)
}),
runs.iter()
.any(|(s, e, c)| { *c == HighlightClass::Keyword && (*s, *e) == (0, 6) }),
"expected `select` keyword span 0..6; got {runs:?}",
);
assert!(
runs.iter().any(|(s, e, c)| {
*c == HighlightClass::Keyword && (*s, *e) == (9, 13)
}),
runs.iter()
.any(|(s, e, c)| { *c == HighlightClass::Keyword && (*s, *e) == (9, 13) }),
"expected `from` keyword span 9..13; got {runs:?}",
);
}
@@ -514,18 +508,37 @@ mod tests {
let insert = keywords_of(
"insert into t (a) values (1) on conflict (a) do update set a = excluded.a returning a",
);
for kw in ["insert", "into", "values", "on", "conflict", "do", "update", "set", "returning"] {
assert!(insert.contains(&kw), "INSERT/UPSERT: missing `{kw}`; got {insert:?}");
for kw in [
"insert",
"into",
"values",
"on",
"conflict",
"do",
"update",
"set",
"returning",
] {
assert!(
insert.contains(&kw),
"INSERT/UPSERT: missing `{kw}`; got {insert:?}"
);
}
let update = keywords_of("update t set a = 1 where id = 2 returning a");
for kw in ["update", "set", "where", "returning"] {
assert!(update.contains(&kw), "UPDATE: missing `{kw}`; got {update:?}");
assert!(
update.contains(&kw),
"UPDATE: missing `{kw}`; got {update:?}"
);
}
let delete = keywords_of("delete from t where id = 1 returning *");
for kw in ["delete", "from", "where", "returning"] {
assert!(delete.contains(&kw), "DELETE: missing `{kw}`; got {delete:?}");
assert!(
delete.contains(&kw),
"DELETE: missing `{kw}`; got {delete:?}"
);
}
}
}
+1 -3
View File
@@ -110,9 +110,7 @@ pub fn consume_number_literal(source: &str, start: usize) -> Option<(usize, usiz
return None;
}
let mut i = start;
let leading_minus = bytes[i] == b'-'
&& i + 1 < bytes.len()
&& bytes[i + 1].is_ascii_digit();
let leading_minus = bytes[i] == b'-' && i + 1 < bytes.len() && bytes[i + 1].is_ascii_digit();
if leading_minus {
i += 1;
}
+430 -559
View File
File diff suppressed because it is too large Load Diff
+110 -38
View File
@@ -14,12 +14,12 @@
//! advanced effective mode (ADR-0037).
use crate::app::EffectiveMode;
use crate::dsl::ReferentialAction;
use crate::dsl::types::Type;
use crate::dsl::Command;
use crate::dsl::ReferentialAction;
use crate::dsl::command::{
ColumnSpec, CompareOp, Constraint, ConstraintKind, Expr, Operand, Predicate, RowFilter,
};
use crate::dsl::types::Type;
use crate::dsl::value::Value;
/// The dimmed `Executing SQL:` prefix on a teaching-echo line
@@ -79,7 +79,12 @@ pub fn echo_for_query(
name,
filter,
limit,
} => Some(vec![render_show_data(name, filter.as_ref(), *limit, primary_key)]),
} => Some(vec![render_show_data(
name,
filter.as_ref(),
*limit,
primary_key,
)]),
_ => None,
}
}
@@ -150,12 +155,12 @@ pub fn command_to_sql(command: &Command) -> Option<String> {
column,
kind,
} => match kind {
ConstraintKind::NotNull => {
Some(format!("ALTER TABLE {table} ALTER COLUMN {column} DROP NOT NULL"))
}
ConstraintKind::Default => {
Some(format!("ALTER TABLE {table} ALTER COLUMN {column} DROP DEFAULT"))
}
ConstraintKind::NotNull => Some(format!(
"ALTER TABLE {table} ALTER COLUMN {column} DROP NOT NULL"
)),
ConstraintKind::Default => Some(format!(
"ALTER TABLE {table} ALTER COLUMN {column} DROP DEFAULT"
)),
// A column-level UNIQUE / CHECK is anonymous in our model —
// no portable name to DROP CONSTRAINT by, so no echo (Bucket C,
// ADR-0035 Amendment 2 residual gap / ADR-0038 §7).
@@ -169,7 +174,10 @@ pub fn command_to_sql(command: &Command) -> Option<String> {
table,
assignments,
filter: RowFilter::AllRows,
} => Some(format!("UPDATE {table} SET {}", render_assignments(assignments))),
} => Some(format!(
"UPDATE {table} SET {}",
render_assignments(assignments)
)),
Command::Delete {
table,
filter: RowFilter::AllRows,
@@ -199,7 +207,13 @@ fn render_create_table(name: &str, columns: &[ColumnSpec], primary_key: &[String
// The same column-constraint suffix `add column` emits (ADR-0029):
// simple-mode `create table` can carry `default` / `check` too, so
// the echo must render them or it is not equivalent (§1 contract).
append_constraints(&mut s, c.not_null, c.unique, c.default.as_ref(), c.check.as_ref());
append_constraints(
&mut s,
c.not_null,
c.unique,
c.default.as_ref(),
c.check.as_ref(),
);
s
})
.collect();
@@ -299,8 +313,10 @@ pub(crate) fn render_create_m2n(
primary_key: &[String],
foreign_keys: &[(Vec<String>, String, Vec<String>)],
) -> String {
let mut parts: Vec<String> =
columns.iter().map(|(n, ty)| format!("{n} {}", ty.keyword())).collect();
let mut parts: Vec<String> = columns
.iter()
.map(|(n, ty)| format!("{n} {}", ty.keyword()))
.collect();
parts.push(format!("PRIMARY KEY ({})", primary_key.join(", ")));
for (child_columns, parent_table, parent_columns) in foreign_keys {
parts.push(format!(
@@ -368,7 +384,12 @@ pub(crate) fn render_add_relationship_create_fk(
) -> Vec<String> {
let mut lines: Vec<String> = new_columns
.iter()
.map(|(col, ty)| format!("ALTER TABLE {child_table} ADD COLUMN {col} {}", ty.keyword()))
.map(|(col, ty)| {
format!(
"ALTER TABLE {child_table} ADD COLUMN {col} {}",
ty.keyword()
)
})
.collect();
lines.push(render_add_relationship(
name,
@@ -461,7 +482,11 @@ fn predicate_to_sql(predicate: &Predicate) -> String {
negated,
} => {
let not = if *negated { "NOT " } else { "" };
format!("{} {not}LIKE {}", operand_to_sql(target), operand_to_sql(pattern))
format!(
"{} {not}LIKE {}",
operand_to_sql(target),
operand_to_sql(pattern)
)
}
Predicate::Between {
target,
@@ -484,7 +509,11 @@ fn predicate_to_sql(predicate: &Predicate) -> String {
} => {
let not = if *negated { "NOT " } else { "" };
let rendered: Vec<String> = items.iter().map(operand_to_sql).collect();
format!("{} {not}IN ({})", operand_to_sql(target), rendered.join(", "))
format!(
"{} {not}IN ({})",
operand_to_sql(target),
rendered.join(", ")
)
}
Predicate::IsNull { target, negated } => {
let not = if *negated { "NOT " } else { "" };
@@ -562,7 +591,10 @@ mod tests {
fn create_table_compound_pk_renders_table_level() {
let cmd = create_table(
"T",
vec![ColumnSpec::new("a", Type::Int), ColumnSpec::new("b", Type::Int)],
vec![
ColumnSpec::new("a", Type::Int),
ColumnSpec::new("b", Type::Int),
],
&["a", "b"],
);
assert_eq!(
@@ -594,7 +626,11 @@ mod tests {
default: Some(Value::Text("A".to_string())),
..ColumnSpec::new("grade", Type::Text)
};
let cmd = create_table("T", vec![ColumnSpec::new("id", Type::Serial), age, grade], &["id"]);
let cmd = create_table(
"T",
vec![ColumnSpec::new("id", Type::Serial), age, grade],
&["id"],
);
let sql = command_to_sql(&cmd).expect("echo");
assert_eq!(
sql,
@@ -625,11 +661,11 @@ mod tests {
check: None,
};
let sql = command_to_sql(&cmd).expect("echo");
assert_eq!(sql, "ALTER TABLE T ADD COLUMN note text NOT NULL DEFAULT 'n/a'");
assert!(matches!(
reparse(&sql),
Ok(Command::SqlAlterTable { .. })
));
assert_eq!(
sql,
"ALTER TABLE T ADD COLUMN note text NOT NULL DEFAULT 'n/a'"
);
assert!(matches!(reparse(&sql), Ok(Command::SqlAlterTable { .. })));
}
#[test]
@@ -657,7 +693,10 @@ mod tests {
})),
};
let sql = command_to_sql(&cmd).expect("echo");
assert_eq!(sql, "ALTER TABLE T ADD COLUMN score int UNIQUE CHECK (score >= 0)");
assert_eq!(
sql,
"ALTER TABLE T ADD COLUMN score int UNIQUE CHECK (score >= 0)"
);
assert!(matches!(reparse(&sql), Ok(Command::SqlAlterTable { .. })));
}
@@ -1031,7 +1070,10 @@ mod tests {
let lines = render_drop_column_cascade(
"Orders",
"CustId",
&["Orders_CustId_idx".to_string(), "Orders_CustId_Day_idx".to_string()],
&[
"Orders_CustId_idx".to_string(),
"Orders_CustId_Day_idx".to_string(),
],
);
assert_eq!(
lines.as_slice(),
@@ -1043,9 +1085,18 @@ mod tests {
);
// Each line is itself runnable advanced-mode SQL (the §1 contract
// holds per line for category 2).
assert!(matches!(reparse(&lines[0]), Ok(Command::SqlDropIndex { .. })));
assert!(matches!(reparse(&lines[1]), Ok(Command::SqlDropIndex { .. })));
assert!(matches!(reparse(&lines[2]), Ok(Command::SqlAlterTable { .. })));
assert!(matches!(
reparse(&lines[0]),
Ok(Command::SqlDropIndex { .. })
));
assert!(matches!(
reparse(&lines[1]),
Ok(Command::SqlDropIndex { .. })
));
assert!(matches!(
reparse(&lines[2]),
Ok(Command::SqlAlterTable { .. })
));
}
#[test]
@@ -1054,7 +1105,10 @@ mod tests {
// plain `DROP COLUMN` — still semantically equivalent.
let lines = render_drop_column_cascade("T", "c", &[]);
assert_eq!(lines.as_slice(), &["ALTER TABLE T DROP COLUMN c"]);
assert!(matches!(reparse(&lines[0]), Ok(Command::SqlAlterTable { .. })));
assert!(matches!(
reparse(&lines[0]),
Ok(Command::SqlAlterTable { .. })
));
}
#[test]
@@ -1078,8 +1132,14 @@ mod tests {
"ALTER TABLE Orders ADD CONSTRAINT Customers_id_to_Orders_CustId FOREIGN KEY (CustId) REFERENCES Customers (id) ON DELETE CASCADE",
]
);
assert!(matches!(reparse(&lines[0]), Ok(Command::SqlAlterTable { .. })));
assert!(matches!(reparse(&lines[1]), Ok(Command::SqlAlterTable { .. })));
assert!(matches!(
reparse(&lines[0]),
Ok(Command::SqlAlterTable { .. })
));
assert!(matches!(
reparse(&lines[1]),
Ok(Command::SqlAlterTable { .. })
));
}
#[test]
@@ -1116,8 +1176,16 @@ mod tests {
],
&["Students_id".to_string(), "Courses_id".to_string()],
&[
(vec!["Students_id".to_string()], "Students".to_string(), vec!["id".to_string()]),
(vec!["Courses_id".to_string()], "Courses".to_string(), vec!["id".to_string()]),
(
vec!["Students_id".to_string()],
"Students".to_string(),
vec!["id".to_string()],
),
(
vec!["Courses_id".to_string()],
"Courses".to_string(),
vec!["id".to_string()],
),
],
);
assert_eq!(
@@ -1172,8 +1240,14 @@ mod tests {
#[test]
fn value_literal_renders_null_uppercase_and_quotes_text() {
assert_eq!(value_to_sql_literal(&Value::Null), "NULL");
assert_eq!(value_to_sql_literal(&Value::Text("O'Hara".to_string())), "'O''Hara'");
assert_eq!(value_to_sql_literal(&Value::Number("3.14".to_string())), "3.14");
assert_eq!(
value_to_sql_literal(&Value::Text("O'Hara".to_string())),
"'O''Hara'"
);
assert_eq!(
value_to_sql_literal(&Value::Number("3.14".to_string())),
"3.14"
);
assert_eq!(value_to_sql_literal(&Value::Bool(false)), "false");
}
@@ -1258,9 +1332,7 @@ mod tests {
"Command::App({app:?}) is Bucket C — no echo"
);
// Also confirm echo_for gates the same in advanced mode.
assert!(
echo_for(&Command::App(app), EffectiveMode::AdvancedPersistent).is_none(),
);
assert!(echo_for(&Command::App(app), EffectiveMode::AdvancedPersistent).is_none(),);
}
}
+10 -5
View File
@@ -8,9 +8,8 @@
use crossterm::event::KeyEvent;
use crate::db::{
AddColumnResult, ChangeColumnTypeResult, DataResult, DbError, DeleteResult,
DropColumnResult, InsertResult, QueryPlan, RelationshipDiagramData, TableDescription,
UpdateResult,
AddColumnResult, ChangeColumnTypeResult, DataResult, DbError, DeleteResult, DropColumnResult,
InsertResult, QueryPlan, RelationshipDiagramData, TableDescription, UpdateResult,
};
use crate::dsl::Command;
@@ -73,10 +72,16 @@ pub enum AppEvent {
},
/// An `explain …` command succeeded (ADR-0028). `plan`
/// carries the captured query plan; nothing was executed.
DslExplainSucceeded { command: Command, plan: QueryPlan },
DslExplainSucceeded {
command: Command,
plan: QueryPlan,
},
/// A `show <kind>` list command (V5) — carries pre-formatted
/// display lines (tables / relationships / indexes).
DslShowListSucceeded { command: Command, lines: Vec<String> },
DslShowListSucceeded {
command: Command,
lines: Vec<String>,
},
/// `show relationship <name>` (ADR-0044) — structured data for the
/// diagram, rendered App-side; `None` when no such relationship.
DslShowRelationshipSucceeded {
+3 -11
View File
@@ -43,17 +43,11 @@ impl Catalog {
}
}
fn flatten(
value: &serde_norway::Value,
prefix: String,
out: &mut HashMap<String, String>,
) {
fn flatten(value: &serde_norway::Value, prefix: String, out: &mut HashMap<String, String>) {
match value {
serde_norway::Value::Mapping(map) => {
for (k, v) in map {
let k_str = k
.as_str()
.expect("catalog keys must be strings");
let k_str = k.as_str().expect("catalog keys must be strings");
let next = if prefix.is_empty() {
k_str.to_string()
} else {
@@ -85,9 +79,7 @@ pub fn catalog() -> &'static Catalog {
/// See module docs for failure modes.
pub fn translate(key: &str, args: &[(&str, &dyn Display)]) -> String {
let template = catalog().get(key).unwrap_or_else(|| {
panic!(
"missing catalog key: `{key}` (the validator should have caught this)"
);
panic!("missing catalog key: `{key}` (the validator should have caught this)");
});
substitute(template, args, key)
}
+24 -29
View File
@@ -41,8 +41,14 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("diagnostic.alias_used_as_column", &["name"]),
("diagnostic.ambiguous_column", &["column", "qualifiers"]),
("diagnostic.auto_column_overridden", &["column", "type"]),
("diagnostic.compound_arity_mismatch", &["op", "left_n", "right_n"]),
("diagnostic.cte_arity_mismatch", &["cte", "declared", "actual"]),
(
"diagnostic.compound_arity_mismatch",
&["op", "left_n", "right_n"],
),
(
"diagnostic.cte_arity_mismatch",
&["cte", "declared", "actual"],
),
("diagnostic.duplicate_cte", &["name"]),
("diagnostic.eq_null", &[]),
("diagnostic.insert_arity_mismatch", &["expected", "actual"]),
@@ -63,7 +69,10 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
),
("diagnostic.not_null_missing", &["column"]),
("diagnostic.like_numeric", &["column", "type"]),
("diagnostic.projection_alias_misplaced", &["alias", "clause"]),
(
"diagnostic.projection_alias_misplaced",
&["alias", "clause"],
),
("diagnostic.table_used_as_column", &["name"]),
("diagnostic.type_mismatch", &["column", "type"]),
("diagnostic.unknown_column", &["name", "table"]),
@@ -149,10 +158,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
"error.type_mismatch.change_column.headline",
&["table", "column", "src_type", "target_type"],
),
(
"error.type_mismatch.change_column.hint",
&["target_type"],
),
("error.type_mismatch.change_column.hint", &["target_type"]),
(
"error.type_mismatch.insert.headline",
&["value", "expected_type"],
@@ -219,10 +225,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("help.data.explain", &[]),
// ---- Hint panel ambient typing assistance (ADR-0022 §6) ----
("hint.ambient_complete", &[]),
(
"hint.ambient_error_with_usage",
&["message", "usage"],
),
("hint.ambient_error_with_usage", &["message", "usage"]),
("hint.ambient_expected", &["expected"]),
("hint.getting_started", &[]),
("hint.block.heading", &[]),
@@ -404,10 +407,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("hint.cmd.explain_sql.what", &[]),
("hint.cmd.explain_sql.example", &[]),
("hint.cmd.explain_sql.concept", &[]),
(
"hint.ambient_invalid_ident",
&["kind", "found"],
),
("hint.ambient_invalid_ident", &["kind", "found"]),
("hint.ambient_typing_name", &[]),
// Issue #4: introduce the advanced-mode CREATE TABLE element
// slot (`create table T (`) so the otherwise-invisible
@@ -415,10 +415,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("hint.create_table_element", &[]),
("hint.seed_count", &[]),
("hint.value_literal_slot", &[]),
(
"hint.ambient_typing_name_then",
&["next"],
),
("hint.ambient_typing_name_then", &["next"]),
// Per-column-type value-slot hints (ADR-0024 §Phase D).
("hint.value_slot_blob", &[]),
("hint.value_slot_bool", &[]),
@@ -441,7 +438,10 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("parse.custom.alter_named_unique", &[]),
("parse.custom.bind_type_mismatch", &["found", "expected"]),
("parse.custom.change_column_flags_exclusive", &[]),
("parse.custom.constraint_redundant_on_pk", &["column", "constraint"]),
(
"parse.custom.constraint_redundant_on_pk",
&["column", "constraint"],
),
("parse.custom.create_table_needs_pk", &[]),
("parse.custom.expression_too_deep", &[]),
("parse.custom.insert_form_a_missing_values", &["columns"]),
@@ -576,10 +576,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
&["table", "col_count", "col_list", "supplied", "non_auto_csv"],
),
("select.internal_table", &["table"]),
(
"cli.invalid_value",
&["flag", "value", "expected"],
),
("cli.invalid_value", &["flag", "value", "expected"]),
("cli.missing_value", &["flag"]),
("cli.multiple_paths", &["first", "second"]),
("cli.resume_with_path", &[]),
@@ -867,8 +864,7 @@ mod tests {
}
}
let declared: HashSet<&str> =
KEYS_AND_PLACEHOLDERS.iter().map(|(k, _)| *k).collect();
let declared: HashSet<&str> = KEYS_AND_PLACEHOLDERS.iter().map(|(k, _)| *k).collect();
for key in cat.keys() {
if key.starts_with("_test.") {
continue;
@@ -890,9 +886,8 @@ mod tests {
/// Mirror of `tests/engine_vocabulary_audit.rs::FORBIDDEN`,
/// duplicated here so the catalog validator is self-contained
/// (no dependency on the integration-test binary).
const FORBIDDEN_ENGINE_VOCABULARY: &[&str] = &[
"SQLite", "sqlite", "rusqlite", "STRICT", "PRAGMA",
];
const FORBIDDEN_ENGINE_VOCABULARY: &[&str] =
&["SQLite", "sqlite", "rusqlite", "STRICT", "PRAGMA"];
/// Detect a `{name:...}` format-specifier placeholder.
/// Doubled braces `{{` / `}}` are escapes — must skip them.
+2 -2
View File
@@ -34,8 +34,8 @@ pub mod keys;
pub mod translate;
pub use error::{DiagnosticTable, FriendlyError};
pub use format::{catalog, Catalog};
pub use translate::{error_hint_class, FailureContext, Operation, TranslateContext, Verbosity};
pub use format::{Catalog, catalog};
pub use translate::{FailureContext, Operation, TranslateContext, Verbosity, error_hint_class};
// `translate::translate` and `format::translate` are different
// callables — the former is the structured DbError → FriendlyError
+79 -76
View File
@@ -201,11 +201,7 @@ impl TranslateContext {
/// Combine schema-resolved facts with operation and
/// verbosity to build the full translator input.
#[must_use]
pub fn from_facts(
operation: Operation,
verbosity: Verbosity,
facts: FailureContext,
) -> Self {
pub fn from_facts(operation: Operation, verbosity: Verbosity, facts: FailureContext) -> Self {
Self {
operation: Some(operation),
table: facts.table,
@@ -234,15 +230,15 @@ pub fn translate(error: &DbError, ctx: &TranslateContext) -> FriendlyError {
// refusal sites). Catalog entries exist for the typed
// invalid-value cases but the migration sweep
// (ADR-0019 §9) is what wires them. For now, passthrough.
DbError::Unsupported(message) | DbError::InvalidValue(message) => {
passthrough(message)
}
DbError::Unsupported(message) | DbError::InvalidValue(message) => passthrough(message),
DbError::PersistenceFatal { message, .. }
| DbError::RebuildRowFailed { detail: message, .. }
| DbError::RebuildRowFailed {
detail: message, ..
}
| DbError::Io(message) => passthrough(message),
DbError::WorkerGone => passthrough(
"the database worker is no longer available — the application must restart",
),
DbError::WorkerGone => {
passthrough("the database worker is no longer available — the application must restart")
}
};
// Attach the row pinpoint when the runtime resolved one.
// The translator never builds the table itself — it only
@@ -320,11 +316,7 @@ const fn fk_hint_class(ctx: &TranslateContext) -> &'static str {
}
}
fn translate_sqlite(
message: &str,
kind: SqliteErrorKind,
ctx: &TranslateContext,
) -> FriendlyError {
fn translate_sqlite(message: &str, kind: SqliteErrorKind, ctx: &TranslateContext) -> FriendlyError {
// `change column ... --dont-convert` lets the engine
// accept or refuse each cell. Whatever the engine returns
// (constraint, datatype mismatch, …) means "the new type
@@ -392,8 +384,8 @@ fn translate_constraint(message: &str, ctx: &TranslateContext) -> FriendlyError
// ---- UNIQUE -----------------------------------------------------
fn translate_unique(message: &str, ctx: &TranslateContext) -> FriendlyError {
let (table, column) = parse_qualified_target(message)
.unwrap_or_else(|| (ctx_table(ctx), ctx_column(ctx)));
let (table, column) =
parse_qualified_target(message).unwrap_or_else(|| (ctx_table(ctx), ctx_column(ctx)));
let value = ctx_value(ctx);
match ctx.operation {
Some(Operation::Update) => fe(
@@ -405,11 +397,7 @@ fn translate_unique(message: &str, ctx: &TranslateContext) -> FriendlyError {
),
verbose_hint(
ctx,
t!(
"error.unique.update.hint",
table = table,
column = column
),
t!("error.unique.update.hint", table = table, column = column),
),
),
// Default to the INSERT variant — it's the most common
@@ -425,11 +413,7 @@ fn translate_unique(message: &str, ctx: &TranslateContext) -> FriendlyError {
),
verbose_hint(
ctx,
t!(
"error.unique.insert.hint",
table = table,
column = column
),
t!("error.unique.insert.hint", table = table, column = column),
),
),
}
@@ -542,8 +526,8 @@ fn fk_parent_side_update(ctx: &TranslateContext) -> FriendlyError {
// ---- NOT NULL --------------------------------------------------
fn translate_not_null(message: &str, ctx: &TranslateContext) -> FriendlyError {
let (table, column) = parse_qualified_target(message)
.unwrap_or_else(|| (ctx_table(ctx), ctx_column(ctx)));
let (table, column) =
parse_qualified_target(message).unwrap_or_else(|| (ctx_table(ctx), ctx_column(ctx)));
match ctx.operation {
Some(Operation::Update) => fe(
t!(
@@ -576,9 +560,17 @@ fn translate_check(_message: &str, ctx: &TranslateContext) -> FriendlyError {
let column = ctx_column(ctx);
let is_update = matches!(ctx.operation, Some(Operation::Update));
let headline = if is_update {
t!("error.check.update.headline", table = table, column = column)
t!(
"error.check.update.headline",
table = table,
column = column
)
} else {
t!("error.check.insert.headline", table = table, column = column)
t!(
"error.check.insert.headline",
table = table,
column = column
)
};
let hint = ctx.check_rule.as_ref().map_or_else(
|| {
@@ -613,8 +605,7 @@ fn translate_check(_message: &str, ctx: &TranslateContext) -> FriendlyError {
// ---- not_found / already_exists --------------------------------
fn translate_not_found_table(message: &str, ctx: &TranslateContext) -> FriendlyError {
let name = parse_after_colon(message)
.map_or_else(|| ctx_table(ctx), str::to_string);
let name = parse_after_colon(message).map_or_else(|| ctx_table(ctx), str::to_string);
headline_only(t!("error.not_found.table.headline", name = name))
}
@@ -656,17 +647,11 @@ fn translate_already_exists(message: &str, ctx: &TranslateContext) -> FriendlyEr
column = column
));
}
return headline_only(t!(
"error.already_exists.table.headline",
name = name
));
return headline_only(t!("error.already_exists.table.headline", name = name));
}
// No backticks — engine-style "table T already exists".
if let Some(name) = parse_after_word(message, "table") {
return headline_only(t!(
"error.already_exists.table.headline",
name = name
));
return headline_only(t!("error.already_exists.table.headline", name = name));
}
if let Some(name) = parse_after_word(message, "relationship") {
return headline_only(t!(
@@ -696,36 +681,25 @@ fn translate_generic(message: &str, ctx: &TranslateContext) -> FriendlyError {
if lower.contains("misuse of aggregate") {
return headline_only(t!("engine.aggregate_misuse", name = "?"));
}
if lower.contains("group by")
|| lower.contains("must appear in")
{
if lower.contains("group by") || lower.contains("must appear in") {
return headline_only(t!("engine.group_by_required"));
}
if (lower.contains("union")
|| lower.contains("intersect")
|| lower.contains("except"))
if (lower.contains("union") || lower.contains("intersect") || lower.contains("except"))
&& lower.contains("result columns")
{
// Last-resort safety net — the pre-flight pass in 2d.1
// catches this in most cases; if the engine surfaces it
// anyway, route it through the engine-neutral key.
return headline_only(t!(
"engine.compound_arity_mismatch",
op = "set operator"
));
return headline_only(t!("engine.compound_arity_mismatch", op = "set operator"));
}
if lower.contains("scalar subquery") || lower.contains("more than one row") {
return headline_only(t!("engine.scalar_subquery_too_many_rows"));
}
if lower.contains("recursive")
&& (lower.contains("cte") || lower.contains("union"))
{
if lower.contains("recursive") && (lower.contains("cte") || lower.contains("union")) {
return headline_only(t!("engine.recursive_cte_malformed"));
}
let operation = ctx
.operation
.map_or("operation", Operation::keyword);
let operation = ctx.operation.map_or("operation", Operation::keyword);
// F2 (ADR-0035 Amendment 1): when no table is in context, use the
// table-less hint so a contextless `friendly_message()` (replay, undo,
// rebuild, export) never renders a literal `{table}` placeholder.
@@ -789,23 +763,33 @@ fn ctx_table(ctx: &TranslateContext) -> String {
}
fn ctx_column(ctx: &TranslateContext) -> String {
ctx.column.clone().unwrap_or_else(|| "the column".to_string())
ctx.column
.clone()
.unwrap_or_else(|| "the column".to_string())
}
fn ctx_value(ctx: &TranslateContext) -> String {
ctx.value.clone().unwrap_or_else(|| "that value".to_string())
ctx.value
.clone()
.unwrap_or_else(|| "that value".to_string())
}
fn ctx_parent_table(ctx: &TranslateContext) -> String {
ctx.parent_table.clone().unwrap_or_else(|| "the referenced table".to_string())
ctx.parent_table
.clone()
.unwrap_or_else(|| "the referenced table".to_string())
}
fn ctx_parent_column(ctx: &TranslateContext) -> String {
ctx.parent_column.clone().unwrap_or_else(|| "the referenced column".to_string())
ctx.parent_column
.clone()
.unwrap_or_else(|| "the referenced column".to_string())
}
fn ctx_child_table(ctx: &TranslateContext) -> String {
ctx.child_table.clone().unwrap_or_else(|| "the referencing table".to_string())
ctx.child_table
.clone()
.unwrap_or_else(|| "the referencing table".to_string())
}
/// Extract `T.col` from a message like
@@ -847,11 +831,7 @@ fn parse_after_word<'a>(message: &'a str, keyword: &str) -> Option<&'a str> {
let rest = message[pos..].trim_start();
let token_end = rest.find(|c: char| c.is_whitespace()).unwrap_or(rest.len());
let token = rest[..token_end].trim_matches(|c: char| c == '`' || c == '\'');
if token.is_empty() {
None
} else {
Some(token)
}
if token.is_empty() { None } else { Some(token) }
}
#[cfg(test)]
@@ -876,15 +856,24 @@ mod tests {
};
let d = TranslateContext::default;
assert_eq!(
error_hint_class(&sqlite(SqliteErrorKind::NoSuchTable, "no such table: X"), &d()),
error_hint_class(
&sqlite(SqliteErrorKind::NoSuchTable, "no such table: X"),
&d()
),
Some("not_found")
);
assert_eq!(
error_hint_class(&sqlite(SqliteErrorKind::NoSuchColumn, "no such column: X"), &d()),
error_hint_class(
&sqlite(SqliteErrorKind::NoSuchColumn, "no such column: X"),
&d()
),
Some("not_found")
);
assert_eq!(
error_hint_class(&sqlite(SqliteErrorKind::AlreadyExists, "already exists"), &d()),
error_hint_class(
&sqlite(SqliteErrorKind::AlreadyExists, "already exists"),
&d()
),
Some("already_exists")
);
assert_eq!(
@@ -933,13 +922,19 @@ mod tests {
parent_table: Some("Parent".to_string()),
..TranslateContext::default()
};
assert_eq!(error_hint_class(&fk(), &ctx), Some("foreign_key.child_side"));
assert_eq!(
error_hint_class(&fk(), &ctx),
Some("foreign_key.child_side")
);
// child_table populated → parent-side.
let ctx = TranslateContext {
child_table: Some("Child".to_string()),
..TranslateContext::default()
};
assert_eq!(error_hint_class(&fk(), &ctx), Some("foreign_key.parent_side"));
assert_eq!(
error_hint_class(&fk(), &ctx),
Some("foreign_key.parent_side")
);
// No enrichment: operation is the tiebreaker.
assert_eq!(
error_hint_class(&fk(), &ctx_with(Operation::Delete)),
@@ -1049,14 +1044,22 @@ mod tests {
ctx.parent_column = Some("country, code".to_string());
ctx.value = Some("7, 8".to_string());
let f = translate(&err, &ctx);
assert!(f.headline.contains("no parent row"), "child-side: {}", f.headline);
assert!(
f.headline.contains("no parent row"),
"child-side: {}",
f.headline
);
assert!(f.headline.contains("Region"));
assert!(
f.headline.contains("country, code"),
"both parent columns must appear: {}",
f.headline
);
assert!(f.headline.contains("`7, 8`"), "joined value: {}", f.headline);
assert!(
f.headline.contains("`7, 8`"),
"joined value: {}",
f.headline
);
}
#[test]
+139 -143
View File
@@ -25,9 +25,9 @@
use ratatui::style::{Color, Modifier, Style};
use crate::dsl::parser::{parse_command_with_schema, parse_command_with_schema_in_mode};
use crate::mode::Mode;
use crate::dsl::walker;
use crate::dsl::{ParseError, parse_command};
use crate::mode::Mode;
use crate::theme::Theme;
/// A run of text with its byte range in the source and the
@@ -85,7 +85,16 @@ pub fn render_input_runs_in_mode(
mode: Mode,
) -> Vec<StyledRun> {
// Identity feedback view — highlight/overlay the whole input.
render_input_runs_feedback(input, cursor_byte, theme, cache, mode, input, cursor_byte, 0)
render_input_runs_feedback(
input,
cursor_byte,
theme,
cache,
mode,
input,
cursor_byte,
0,
)
}
/// [`render_input_runs_in_mode`] with a separate **feedback view** for
@@ -121,12 +130,14 @@ pub fn render_input_runs_feedback(
byte_range: (0, offset),
style: ratatui::style::Style::default().fg(theme.fg),
}];
r.extend(lex_to_runs_in_mode(view, theme, mode).into_iter().map(|run| {
StyledRun {
byte_range: (run.byte_range.0 + offset, run.byte_range.1 + offset),
..run
}
}));
r.extend(
lex_to_runs_in_mode(view, theme, mode)
.into_iter()
.map(|run| StyledRun {
byte_range: (run.byte_range.0 + offset, run.byte_range.1 + offset),
..run
}),
);
r
};
if let InputState::DefiniteErrorAt(pos) =
@@ -150,7 +161,11 @@ pub fn render_input_runs_feedback(
walker::Severity::Error => theme.tok_error,
walker::Severity::Warning => theme.warning,
};
overlay_span(&mut runs, (diag.span.0 + offset, diag.span.1 + offset), colour);
overlay_span(
&mut runs,
(diag.span.0 + offset, diag.span.1 + offset),
colour,
);
}
inject_cursor(&mut runs, input, cursor_byte, theme);
runs
@@ -234,9 +249,7 @@ pub fn classify_input_with_schema_in_mode(
))
}
fn classify_parse_result(
result: Result<crate::dsl::Command, ParseError>,
) -> InputState {
fn classify_parse_result(result: Result<crate::dsl::Command, ParseError>) -> InputState {
match result {
Ok(_) => InputState::Valid,
Err(ParseError::Empty) => InputState::Empty,
@@ -372,8 +385,7 @@ pub fn advanced_alternative_note(
// carries a blocking ERROR diagnostic such as a value-count
// mismatch. Incomplete input (still being typed) and empty input are
// excluded so the pointer doesn't flicker mid-keystroke.
let definite_dsl_error = match classify_input_with_schema_in_mode(input, cache, Mode::Simple)
{
let definite_dsl_error = match classify_input_with_schema_in_mode(input, cache, Mode::Simple) {
InputState::DefiniteErrorAt(_) => true,
InputState::Valid => {
crate::dsl::walker::input_verdict_in_mode(input, Some(cache), Mode::Simple)
@@ -386,8 +398,7 @@ pub fn advanced_alternative_note(
}
// The validity-verdict-driven gate (ADR-0033 Amendment 5): the
// line must be fully valid (verdict `None`) in advanced mode.
if crate::dsl::walker::input_verdict_in_mode(input, Some(cache), Mode::Advanced).is_some()
{
if crate::dsl::walker::input_verdict_in_mode(input, Some(cache), Mode::Advanced).is_some() {
return None;
}
Some(crate::t!("advanced_mode.also_valid_sql"))
@@ -714,8 +725,7 @@ fn ambient_hint_core_in_mode(
// narrows column candidates to the active table and runs the
// §10.6 look-ahead, so it is the authoritative "what can go
// here" set.
let completion =
crate::completion::candidates_at_cursor_in_mode(input, cursor, cache, mode);
let completion = crate::completion::candidates_at_cursor_in_mode(input, cursor, cache, mode);
// Schema-aware diagnostics (ADR-0027 §2). `input_diagnostics`
// is non-empty only for a command that *structurally parses*
@@ -834,7 +844,9 @@ fn ambient_hint_core_in_mode(
// keyword set.
return Some(AmbientHint::Prose(crate::friendly::translate(key, &[])));
}
Some(crate::dsl::grammar::HintMode::SuppressProse | crate::dsl::grammar::HintMode::Default)
Some(
crate::dsl::grammar::HintMode::SuppressProse | crate::dsl::grammar::HintMode::Default,
)
| None => {}
}
@@ -855,7 +867,8 @@ fn ambient_hint_core_in_mode(
// Invalid identifier: cursor sits in a known-set slot but
// the typed prefix matches nothing in the schema. (Stage
// 8e / the user's #5.)
if let Some(inv) = crate::completion::invalid_ident_at_cursor_in_mode(input, cursor, cache, mode)
if let Some(inv) =
crate::completion::invalid_ident_at_cursor_in_mode(input, cursor, cache, mode)
{
let kind = match inv.source {
crate::dsl::grammar::IdentSource::Tables => "table",
@@ -1036,11 +1049,7 @@ pub fn lex_to_runs(input: &str, theme: &Theme) -> Vec<StyledRun> {
/// with `Mode::Advanced` so SQL keywords past the entry word
/// match and get highlighted (ADR-0030 §8).
#[must_use]
pub fn lex_to_runs_in_mode(
input: &str,
theme: &Theme,
mode: Mode,
) -> Vec<StyledRun> {
pub fn lex_to_runs_in_mode(input: &str, theme: &Theme, mode: Mode) -> Vec<StyledRun> {
base_runs(input, theme, mode)
}
@@ -1076,12 +1085,7 @@ fn base_runs(input: &str, theme: &Theme, mode: Mode) -> Vec<StyledRun> {
runs
}
fn inject_cursor(
runs: &mut Vec<StyledRun>,
input: &str,
cursor_byte: usize,
theme: &Theme,
) {
fn inject_cursor(runs: &mut Vec<StyledRun>, input: &str, cursor_byte: usize, theme: &Theme) {
let cursor_byte = cursor_byte.min(input.len());
// End-of-input cursor: append the empty-range sentinel.
@@ -1164,9 +1168,10 @@ mod tests {
let mut cache = SchemaCache::default();
cache.tables.push("Customers".into());
cache.columns.push("name".into());
cache
.table_columns
.insert("Customers".into(), vec![TableColumn::new("name", Type::Text)]);
cache.table_columns.insert(
"Customers".into(),
vec![TableColumn::new("name", Type::Text)],
);
let input = ": select name from Customers";
let view = "select name from Customers";
let offset = 2; // ": "
@@ -1362,9 +1367,10 @@ mod tests {
let mut cache = crate::completion::SchemaCache::default();
cache.tables.push("users".to_string());
cache.columns.push("email".to_string());
cache
.table_columns
.insert("users".to_string(), vec![TableColumn::new("email", Type::Text)]);
cache.table_columns.insert(
"users".to_string(),
vec![TableColumn::new("email", Type::Text)],
);
cache
}
@@ -1392,7 +1398,10 @@ mod tests {
}
// Tab candidates remain available (completion is independent).
let comp = crate::completion::candidates_at_cursor_in_mode(
input, input.len(), &cache, Mode::Simple,
input,
input.len(),
&cache,
Mode::Simple,
)
.expect("completion remains available");
let texts: Vec<&str> = comp.candidates.iter().map(|c| c.text.as_str()).collect();
@@ -1424,7 +1433,10 @@ mod tests {
&hint,
Some(AmbientHint::Prose(p)) if p.contains("row count")
);
assert!(!is_count_prose, "count hint must not show for {input:?}; got {hint:?}");
assert!(
!is_count_prose,
"count hint must not show for {input:?}; got {hint:?}"
);
}
}
@@ -1502,14 +1514,12 @@ mod tests {
use crate::dsl::types::Type;
let mut cache = SchemaCache::default();
cache.tables.push("Customers".to_string());
let tc = vec![
TableColumn {
name: "Age".to_string(),
user_type: Type::Int,
not_null: false,
has_default: false,
},
];
let tc = vec![TableColumn {
name: "Age".to_string(),
user_type: Type::Int,
not_null: false,
has_default: false,
}];
for c in &tc {
cache.columns.push(c.name.clone());
}
@@ -1551,9 +1561,7 @@ mod tests {
p.contains("No such") && p.contains("Agx"),
"a genuine column typo before FROM must warn at typing time; got: {p:?}",
),
other => panic!(
"`select Agx` must surface a typing-time typo hint; got: {other:?}",
),
other => panic!("`select Agx` must surface a typing-time typo hint; got: {other:?}",),
}
}
@@ -1652,8 +1660,7 @@ mod tests {
// ADR-0022 Amendment 1: advanced-mode ambient assistance
// surfaces SQL completion candidates (here the FROM-slot
// table) instead of the simple-mode "this is SQL" gate.
let cache =
schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]);
let cache = schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]);
let input = "select * from ";
match ambient_hint_in_mode(
input,
@@ -1678,10 +1685,7 @@ mod tests {
// `INSERT … (` column list. (The simple-mode DSL value-slot
// prose is a separate surface; this pins the §8 advanced claim.)
use crate::dsl::types::Type;
let cache = schema_with_columns(
"Customers",
&[("id", Type::Int), ("Name", Type::Text)],
);
let cache = schema_with_columns("Customers", &[("id", Type::Int), ("Name", Type::Text)]);
let set_slot = "update Customers set ";
match ambient_hint_in_mode(set_slot, set_slot.len(), None, &cache, Mode::Advanced) {
@@ -1706,16 +1710,10 @@ mod tests {
fn simple_mode_ambient_does_not_surface_sql_candidates() {
// The simple-mode entry point keeps gating SQL — advanced
// assistance is opt-in via mode, never leaked into simple.
let cache =
schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]);
let cache = schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]);
let input = "select * from ";
let hint = ambient_hint_in_mode(
input,
input.len(),
None,
&cache,
crate::mode::Mode::Simple,
);
let hint =
ambient_hint_in_mode(input, input.len(), None, &cache, crate::mode::Mode::Simple);
let offers_table = matches!(
&hint,
Some(AmbientHint::Candidates { items, .. })
@@ -1733,8 +1731,7 @@ mod tests {
fn f1_mid_typed_table_prefix_shows_completion_not_error() {
// "select * from c" — `c` prefix-matches `Customers`. The
// hint must offer the completion, not "no such table c".
let cache =
schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]);
let cache = schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]);
match ambient_hint_in_mode(
"select * from c",
"select * from c".len(),
@@ -1753,8 +1750,7 @@ mod tests {
#[test]
fn f1_genuinely_unknown_table_still_shows_error() {
// "zzz" matches no table prefix — the error must still show.
let cache =
schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]);
let cache = schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]);
match ambient_hint_in_mode(
"select * from zzz",
"select * from zzz".len(),
@@ -1773,8 +1769,7 @@ mod tests {
fn f1_simple_mode_dsl_mid_typed_table_completes() {
// The same shadowing affects DSL commands in simple mode:
// "show data c" must offer Customers, not "no such table c".
let cache =
schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]);
let cache = schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]);
match ambient_hint_in_mode(
"show data c",
"show data c".len(),
@@ -1804,7 +1799,12 @@ mod tests {
cache.columns.push("order_col".to_string());
cache.table_columns.insert(
"Orders".to_string(),
vec![TableColumn { name: "order_col".to_string(), user_type: Type::Int, not_null: false, has_default: false }],
vec![TableColumn {
name: "order_col".to_string(),
user_type: Type::Int,
not_null: false,
has_default: false,
}],
);
let comp = crate::completion::candidates_at_cursor_in_mode(
@@ -1846,9 +1846,7 @@ mod tests {
for c in &columns {
cache.columns.push(c.name.clone());
}
cache
.table_columns
.insert(table.to_string(), columns);
cache.table_columns.insert(table.to_string(), columns);
cache
}
@@ -1860,7 +1858,11 @@ mod tests {
("Customers", &[("id", Type::Serial), ("name", Type::Text)]),
(
"Products",
&[("id", Type::Serial), ("name", Type::Text), ("price", Type::Decimal)],
&[
("id", Type::Serial),
("name", Type::Text),
("price", Type::Decimal),
],
),
(
"OrderLines",
@@ -1873,13 +1875,19 @@ mod tests {
),
(
"Orders",
&[("id", Type::Serial), ("customer_id", Type::Int), ("date", Type::Date)],
&[
("id", Type::Serial),
("customer_id", Type::Int),
("date", Type::Date),
],
),
];
for (t, cols) in tables {
cache.tables.push((*t).to_string());
let tc: Vec<TableColumn> =
cols.iter().map(|(n, ty)| TableColumn::new(*n, *ty)).collect();
let tc: Vec<TableColumn> = cols
.iter()
.map(|(n, ty)| TableColumn::new(*n, *ty))
.collect();
for c in &tc {
cache.columns.push(c.name.clone());
}
@@ -1914,17 +1922,11 @@ mod tests {
#[test]
fn ambient_hint_at_insert_first_value_shows_int_prose() {
use crate::dsl::types::Type;
let cache = schema_with_columns(
"Customers",
&[("id", Type::Int), ("Name", Type::Text)],
);
let cache = schema_with_columns("Customers", &[("id", Type::Int), ("Name", Type::Text)]);
let input = "insert into Customers values (";
match ambient_hint(input, input.len(), None, &cache) {
Some(AmbientHint::Prose(p)) => {
assert!(
p.contains("integer"),
"expected int-slot prose, got: {p:?}",
);
assert!(p.contains("integer"), "expected int-slot prose, got: {p:?}",);
}
other => panic!("expected Prose, got {other:?}"),
}
@@ -1942,7 +1944,11 @@ mod tests {
use crate::dsl::types::Type;
let cache = schema_with_columns(
"Customers",
&[("id", Type::Serial), ("Name", Type::Text), ("Email", Type::Text)],
&[
("id", Type::Serial),
("Name", Type::Text),
("Email", Type::Text),
],
);
let input = "insert into Customers values (1, 'Alice', 'a@b.c')";
match ambient_hint(input, input.len(), None, &cache) {
@@ -1966,10 +1972,7 @@ mod tests {
// A valid simple-mode DSL command gets no advanced pointer —
// it isn't an error, and there is nothing to switch modes for.
use crate::dsl::types::Type;
let cache = schema_with_columns(
"Customers",
&[("id", Type::Serial), ("Name", Type::Text)],
);
let cache = schema_with_columns("Customers", &[("id", Type::Serial), ("Name", Type::Text)]);
let input = "insert into Customers values ('Alice')";
if let Some(AmbientHint::Prose(p)) = ambient_hint(input, input.len(), None, &cache) {
assert!(
@@ -2010,10 +2013,7 @@ mod tests {
#[test]
fn ambient_hint_at_insert_second_value_shows_text_prose() {
use crate::dsl::types::Type;
let cache = schema_with_columns(
"Customers",
&[("id", Type::Int), ("Name", Type::Text)],
);
let cache = schema_with_columns("Customers", &[("id", Type::Int), ("Name", Type::Text)]);
let input = "insert into Customers values (1, ";
match ambient_hint(input, input.len(), None, &cache) {
Some(AmbientHint::Prose(p)) => {
@@ -2029,10 +2029,8 @@ mod tests {
#[test]
fn ambient_hint_at_update_set_shows_per_column_prose() {
use crate::dsl::types::Type;
let cache = schema_with_columns(
"Customers",
&[("id", Type::Int), ("Birthday", Type::Date)],
);
let cache =
schema_with_columns("Customers", &[("id", Type::Int), ("Birthday", Type::Date)]);
let input = "update Customers set Birthday=";
match ambient_hint(input, input.len(), None, &cache) {
Some(AmbientHint::Prose(p)) => {
@@ -2057,9 +2055,7 @@ mod tests {
// hasn't committed), the Optional propagates Incomplete
// and the user sees no error overlay until they submit.
assert_eq!(
classify_input(
"insert into Orders (id, CustId, Total) values (42, 89, 17.59"
),
classify_input("insert into Orders (id, CustId, Total) values (42, 89, 17.59"),
InputState::IncompleteAtEof,
);
assert_eq!(
@@ -2071,18 +2067,12 @@ mod tests {
#[test]
fn ambient_hint_at_insert_first_value_mentions_column_name() {
use crate::dsl::types::Type;
let cache = schema_with_columns(
"Customers",
&[("id", Type::Int), ("Name", Type::Text)],
);
let cache = schema_with_columns("Customers", &[("id", Type::Int), ("Name", Type::Text)]);
let input = "insert into Customers values (";
match ambient_hint(input, input.len(), None, &cache) {
Some(AmbientHint::Prose(p)) => {
assert!(p.contains("id"), "expected column name `id`, got {p:?}");
assert!(
p.contains("integer"),
"expected int prose, got {p:?}",
);
assert!(p.contains("integer"), "expected int prose, got {p:?}",);
}
other => panic!("expected Prose, got {other:?}"),
}
@@ -2091,10 +2081,7 @@ mod tests {
#[test]
fn ambient_hint_at_update_set_mentions_column_name() {
use crate::dsl::types::Type;
let cache = schema_with_columns(
"Customers",
&[("id", Type::Int), ("Email", Type::Text)],
);
let cache = schema_with_columns("Customers", &[("id", Type::Int), ("Email", Type::Text)]);
let input = "update Customers set Email=";
match ambient_hint(input, input.len(), None, &cache) {
Some(AmbientHint::Prose(p)) => {
@@ -2131,10 +2118,7 @@ mod tests {
#[test]
fn ambient_hint_at_second_insert_value_mentions_second_column() {
use crate::dsl::types::Type;
let cache = schema_with_columns(
"Customers",
&[("id", Type::Int), ("Name", Type::Text)],
);
let cache = schema_with_columns("Customers", &[("id", Type::Int), ("Name", Type::Text)]);
let input = "insert into Customers values (1, ";
match ambient_hint(input, input.len(), None, &cache) {
Some(AmbientHint::Prose(p)) => {
@@ -2165,14 +2149,20 @@ mod tests {
use crate::dsl::types::Type;
let cases: &[(&[(&str, Type)], &str)] = &[
// string first value (the report's case): first col text.
(&[("Name", Type::Text), ("Age", Type::Int)],
"insert into Customers values ('Oli'"),
(
&[("Name", Type::Text), ("Age", Type::Int)],
"insert into Customers values ('Oli'",
),
// integer first value: first col int.
(&[("Age", Type::Int), ("Name", Type::Text)],
"insert into Customers values (42"),
(
&[("Age", Type::Int), ("Name", Type::Text)],
"insert into Customers values (42",
),
// real first value: first col real.
(&[("Score", Type::Real), ("Name", Type::Text)],
"insert into Customers values (3.5"),
(
&[("Score", Type::Real), ("Name", Type::Text)],
"insert into Customers values (3.5",
),
];
for (cols, input) in cases {
let cache = schema_with_columns("Customers", cols);
@@ -2232,10 +2222,7 @@ mod tests {
// is nothing left to fill. Guards against over-correcting the
// fix into never suggesting the close paren.
use crate::dsl::types::Type;
let cache = schema_with_columns(
"Customers",
&[("Name", Type::Text), ("Age", Type::Int)],
);
let cache = schema_with_columns("Customers", &[("Name", Type::Text), ("Age", Type::Int)]);
let input = "insert into Customers values ('Oli', 52";
match ambient_hint(input, input.len(), None, &cache) {
Some(AmbientHint::Prose(p)) => {
@@ -2384,10 +2371,7 @@ mod tests {
match ambient_hint("show data Missing", 17, None, &cache) {
Some(AmbientHint::Prose(p)) => {
assert!(p.contains("Missing"), "got {p:?}");
assert!(
p.to_lowercase().contains("no such table"),
"got {p:?}",
);
assert!(p.to_lowercase().contains("no such table"), "got {p:?}",);
}
other => panic!("expected Prose, got {other:?}"),
}
@@ -2440,8 +2424,7 @@ mod tests {
use crate::dsl::types::Type;
// Two type-mismatch WARNINGs; the hint names the column
// whose offending literal the cursor sits in.
let cache =
schema_with_columns("Events", &[("a", Type::Int), ("b", Type::Int)]);
let cache = schema_with_columns("Events", &[("a", Type::Int), ("b", Type::Int)]);
let input = "delete from Events where a = 'x' or b = 'y'";
let on_x = input.find("'x'").expect("'x' literal") + 1;
let on_y = input.find("'y'").expect("'y' literal") + 1;
@@ -2460,8 +2443,16 @@ mod tests {
inserted_range: (5, 5),
original_text: String::new(),
candidates: vec![
Candidate { text: "data".to_string(), kind: CandidateKind::Keyword, mode: crate::completion::ModeClass::Both },
Candidate { text: "table".to_string(), kind: CandidateKind::Keyword, mode: crate::completion::ModeClass::Both },
Candidate {
text: "data".to_string(),
kind: CandidateKind::Keyword,
mode: crate::completion::ModeClass::Both,
},
Candidate {
text: "table".to_string(),
kind: CandidateKind::Keyword,
mode: crate::completion::ModeClass::Both,
},
],
selection_idx: 1,
};
@@ -2494,8 +2485,16 @@ mod tests {
// produce — proves the memo's list is being used,
// not a recomputed one.
candidates: vec![
Candidate { text: "data".to_string(), kind: CandidateKind::Keyword, mode: crate::completion::ModeClass::Both },
Candidate { text: "table".to_string(), kind: CandidateKind::Keyword, mode: crate::completion::ModeClass::Both },
Candidate {
text: "data".to_string(),
kind: CandidateKind::Keyword,
mode: crate::completion::ModeClass::Both,
},
Candidate {
text: "table".to_string(),
kind: CandidateKind::Keyword,
mode: crate::completion::ModeClass::Both,
},
],
selection_idx: 1,
};
@@ -2564,10 +2563,7 @@ mod tests {
fn classify_trailing_whitespace_does_not_create_definite_error() {
// Trailing whitespace alone shouldn't promote an
// incomplete-at-EOF state into a definite error.
assert_eq!(
classify_input("create "),
InputState::IncompleteAtEof,
);
assert_eq!(classify_input("create "), InputState::IncompleteAtEof,);
}
#[test]
+3 -4
View File
@@ -60,8 +60,8 @@ pub fn init(path: Option<&Path>) -> Result<PathBuf> {
.with_context(|| format!("create log directory {}", parent.display()))?;
}
let file = open_log_file(&chosen)?;
let filter = EnvFilter::try_from_env("RDBMS_PLAYGROUND_LOG")
.unwrap_or_else(|_| EnvFilter::new("info"));
let filter =
EnvFilter::try_from_env("RDBMS_PLAYGROUND_LOG").unwrap_or_else(|_| EnvFilter::new("info"));
let layer = fmt::layer()
.with_writer(file)
.with_ansi(false)
@@ -95,8 +95,7 @@ fn home_dir() -> Option<PathBuf> {
if let Some(p) = std::env::var_os("HOME") {
return Some(PathBuf::from(p));
}
if let (Some(drive), Some(path)) =
(std::env::var_os("HOMEDRIVE"), std::env::var_os("HOMEPATH"))
if let (Some(drive), Some(path)) = (std::env::var_os("HOMEDRIVE"), std::env::var_os("HOMEPATH"))
{
let mut combined = PathBuf::from(drive);
combined.push(path);
+1 -1
View File
@@ -1,6 +1,6 @@
use std::process::ExitCode;
use rdbms_playground::cli::{help_text, version_text, Args};
use rdbms_playground::cli::{Args, help_text, version_text};
use rdbms_playground::{logging, runtime};
fn main() -> ExitCode {
+69 -42
View File
@@ -172,7 +172,10 @@ fn constraint_lines(desc: &TableDescription) -> Vec<String> {
/// A `detail` matching no marker renders neutral — the engine's
/// plan vocabulary may grow (ADR-0028 §4).
const PLAN_TAXONOMY: &[(&str, OutputStyleClass)] = &[
("USING AUTOMATIC COVERING INDEX", OutputStyleClass::AutomaticIndex),
(
"USING AUTOMATIC COVERING INDEX",
OutputStyleClass::AutomaticIndex,
),
("USING AUTOMATIC INDEX", OutputStyleClass::AutomaticIndex),
("USING COVERING INDEX", OutputStyleClass::Efficient),
("USING INTEGER PRIMARY KEY", OutputStyleClass::Efficient),
@@ -225,8 +228,7 @@ fn render_plan_subtree(
emitted: &mut HashSet<i64>,
mode: Mode,
) {
let children: Vec<&ExplainRow> =
rows.iter().filter(|r| r.parent == parent).collect();
let children: Vec<&ExplainRow> = rows.iter().filter(|r| r.parent == parent).collect();
let last_idx = children.len().saturating_sub(1);
for (idx, row) in children.iter().enumerate() {
if !emitted.insert(row.id) {
@@ -235,8 +237,7 @@ fn render_plan_subtree(
let is_last = idx == last_idx;
let connector = if is_last { "└─ " } else { "├─ " };
out.push(plan_node_line(prefix, connector, &row.detail, mode));
let child_prefix =
format!("{prefix}{}", if is_last { " " } else { "" });
let child_prefix = format!("{prefix}{}", if is_last { " " } else { "" });
render_plan_subtree(rows, row.id, &child_prefix, out, emitted, mode);
}
}
@@ -343,13 +344,8 @@ pub fn render_diagnostic_table(
const fn alignment_for(ty: Option<Type>) -> Alignment {
match ty {
Some(Type::Int | Type::Real | Type::Decimal | Type::Serial) => Alignment::Right,
Some(Type::Text)
| Some(Type::Bool)
| Some(Type::Date)
| Some(Type::DateTime)
| Some(Type::Blob)
| Some(Type::ShortId)
| None => Alignment::Left,
Some(Type::Text) | Some(Type::Bool) | Some(Type::Date) | Some(Type::DateTime)
| Some(Type::Blob) | Some(Type::ShortId) | None => Alignment::Left,
}
}
@@ -406,11 +402,7 @@ fn cell_width(s: &str) -> usize {
/// Render a single bordered table given header cells, body
/// rows, and per-column alignment. Outer frame +
/// header-underline only.
fn render_table(
headers: &[String],
body: &[Vec<String>],
alignments: &[Alignment],
) -> Vec<String> {
fn render_table(headers: &[String], body: &[Vec<String>], alignments: &[Alignment]) -> Vec<String> {
debug_assert_eq!(headers.len(), alignments.len());
// Compute column widths: max(header, all body cells).
@@ -792,13 +784,12 @@ fn gutter_seg(i: usize, child_rows: &[usize], parent_rows: &[usize], w: usize) -
}
// The vertical bus spans the full range of endpoint rows.
let bounds = child_rows
.iter()
.chain(parent_rows)
.copied()
.fold(None, |acc: Option<(usize, usize)>, r| {
let bounds = child_rows.iter().chain(parent_rows).copied().fold(
None,
|acc: Option<(usize, usize)>, r| {
Some(acc.map_or((r, r), |(lo, hi)| (lo.min(r), hi.max(r))))
});
},
);
if let Some((top, bot)) = bounds
&& i >= top
&& i <= bot
@@ -1138,7 +1129,10 @@ mod tests {
assert!(out.contains("customer_id ●"), "FK marker:\n{out}");
assert!(out.contains("id (PK) ●"), "parent endpoint marker:\n{out}");
assert!(out.contains('▶'), "arrowhead:\n{out}");
assert!(out.contains('n') && out.contains('1'), "cardinality:\n{out}");
assert!(
out.contains('n') && out.contains('1'),
"cardinality:\n{out}"
);
assert!(
out.contains("on delete cascade · on update no action"),
"actions:\n{out}"
@@ -1237,7 +1231,10 @@ mod tests {
let (r_out, r_in) = blank_rels();
let region = TableDescription {
name: "Region".to_string(),
columns: vec![col("country", Type::Int, true, false), col("code", Type::Int, true, false)],
columns: vec![
col("country", Type::Int, true, false),
col("code", Type::Int, true, false),
],
outbound_relationships: r_out,
inbound_relationships: r_in,
indexes: Vec::new(),
@@ -1277,7 +1274,10 @@ mod tests {
.collect::<Vec<_>>()
.join("\n");
assert!(text.contains("region_code ●"), "child endpoint 2:\n{text}");
assert!(text.contains("(PK) ●"), "parent endpoint is PK + marked:\n{text}");
assert!(
text.contains("(PK) ●"),
"parent endpoint is PK + marked:\n{text}"
);
assert!(
text.contains("(country, region_code) ▶ Region.(country, code)"),
"pairing line:\n{text}",
@@ -1412,11 +1412,7 @@ mod tests {
let data = DataResult {
table_name: "Customers".to_string(),
columns: vec!["id".to_string(), "Name".to_string(), "Email".to_string()],
column_types: vec![
Some(Type::Serial),
Some(Type::Text),
Some(Type::Text),
],
column_types: vec![Some(Type::Serial), Some(Type::Text), Some(Type::Text)],
rows: vec![
vec![
Some("1".to_string()),
@@ -1634,7 +1630,10 @@ mod tests {
assert!(out.contains("Indexes:"), "got:\n{out}");
assert!(out.contains("idx_email (Email)"), "got:\n{out}");
// A plain index carries no uniqueness marker.
assert!(!out.contains("[unique]"), "plain index unmarked; got:\n{out}");
assert!(
!out.contains("[unique]"),
"plain index unmarked; got:\n{out}"
);
}
#[test]
@@ -1677,7 +1676,10 @@ mod tests {
indexes: Vec::new(),
unique_constraints: vec![vec!["a".to_string(), "b".to_string()]],
check_constraints: vec![
crate::persistence::TableCheck { name: None, expr: "a < b".to_string() },
crate::persistence::TableCheck {
name: None,
expr: "a < b".to_string(),
},
crate::persistence::TableCheck {
name: Some("a_lt_b".to_string()),
expr: "a <> b".to_string(),
@@ -1691,7 +1693,10 @@ mod tests {
// (ADR-0035 Amendment 1) so the user can `drop constraint <name>`.
assert!(out.contains("unique_a_b: unique (a, b)"), "got:\n{out}");
assert!(out.contains("check (a < b)"), "unnamed check; got:\n{out}");
assert!(out.contains("check a_lt_b (a <> b)"), "named check shows its name; got:\n{out}");
assert!(
out.contains("check a_lt_b (a <> b)"),
"named check shows its name; got:\n{out}"
);
}
#[test]
@@ -1732,17 +1737,37 @@ mod tests {
let plan = QueryPlan {
display_sql: "SELECT 1".to_string(),
rows: vec![
ExplainRow { id: 1, parent: 0, detail: "root".to_string() },
ExplainRow { id: 2, parent: 1, detail: "child-a".to_string() },
ExplainRow { id: 3, parent: 1, detail: "child-b".to_string() },
ExplainRow {
id: 1,
parent: 0,
detail: "root".to_string(),
},
ExplainRow {
id: 2,
parent: 1,
detail: "child-a".to_string(),
},
ExplainRow {
id: 3,
parent: 1,
detail: "child-b".to_string(),
},
],
};
let lines = render_explain_plan(&plan, Mode::Simple);
// display SQL + 3 plan nodes.
assert_eq!(lines.len(), 4);
assert!(lines[1].text.contains("root"));
assert!(lines[2].text.contains("├─ child-a"), "got {:?}", lines[2].text);
assert!(lines[3].text.contains("─ child-b"), "got {:?}", lines[3].text);
assert!(
lines[2].text.contains("─ child-a"),
"got {:?}",
lines[2].text
);
assert!(
lines[3].text.contains("└─ child-b"),
"got {:?}",
lines[3].text
);
// The single root uses `└─`; its children are indented
// by three spaces (no `│` spine, the root being last).
assert!(lines[1].text.starts_with("└─ root"));
@@ -1775,7 +1800,10 @@ mod tests {
fn render_explain_plan_colours_a_full_scan_expensive() {
let plan = one_node_plan("SCAN Customers");
let lines = render_explain_plan(&plan, Mode::Simple);
assert_eq!(span_class_for(&lines[1], "SCAN"), OutputStyleClass::Expensive);
assert_eq!(
span_class_for(&lines[1], "SCAN"),
OutputStyleClass::Expensive
);
// The table name stays neutral (ADR-0028 §6).
assert_eq!(
span_class_for(&lines[1], "Customers"),
@@ -1801,8 +1829,7 @@ mod tests {
#[test]
fn render_explain_plan_flags_an_automatic_index() {
let plan =
one_node_plan("SEARCH Orders USING AUTOMATIC COVERING INDEX (CustId=?)");
let plan = one_node_plan("SEARCH Orders USING AUTOMATIC COVERING INDEX (CustId=?)");
let lines = render_explain_plan(&plan, Mode::Simple);
assert_eq!(
span_class_for(&lines[1], "USING AUTOMATIC COVERING INDEX"),
+29 -13
View File
@@ -150,7 +150,9 @@ fn encode_cell(ty: Type, value: &CellValue) -> Result<Cell, String> {
other => Err(format!("expected date/datetime (text), got {other:?}")),
},
Type::Blob => match value {
CellValue::Blob(bytes) => Ok(Cell::Plain(base64::engine::general_purpose::STANDARD.encode(bytes))),
CellValue::Blob(bytes) => Ok(Cell::Plain(
base64::engine::general_purpose::STANDARD.encode(bytes),
)),
other => Err(format!("expected blob, got {other:?}")),
},
Type::Serial => match value {
@@ -169,7 +171,11 @@ fn format_real(f: f64) -> String {
if f.is_nan() {
"nan".to_string()
} else if f.is_infinite() {
if f > 0.0 { "inf".to_string() } else { "-inf".to_string() }
if f > 0.0 {
"inf".to_string()
} else {
"-inf".to_string()
}
} else {
// Default `{}` formatting on f64 emits a shortest
// round-tripping decimal — exactly what the ADR asks
@@ -318,8 +324,7 @@ fn parse_field(bytes: &[u8]) -> Result<(RawCell, usize), CsvError> {
_ => i += 1,
}
}
let content =
String::from_utf8(bytes[..i].to_vec()).map_err(|_| CsvError::InvalidUtf8)?;
let content = String::from_utf8(bytes[..i].to_vec()).map_err(|_| CsvError::InvalidUtf8)?;
Ok((
RawCell {
content,
@@ -435,7 +440,10 @@ mod tests {
name: "T".to_string(),
columns: vec![col("n", Type::Int), col("r", Type::Real)],
rows: vec![
vec![CellValue::Integer(42), CellValue::Real(std::f64::consts::PI)],
vec![
CellValue::Integer(42),
CellValue::Real(std::f64::consts::PI),
],
vec![CellValue::Integer(-7), CellValue::Real(0.0)],
],
})
@@ -452,10 +460,7 @@ mod tests {
let body = serialize_table(&TableSnapshot {
name: "T".to_string(),
columns: vec![col("b", Type::Bool)],
rows: vec![
vec![CellValue::Integer(1)],
vec![CellValue::Integer(0)],
],
rows: vec![vec![CellValue::Integer(1)], vec![CellValue::Integer(0)]],
})
.unwrap();
let s = String::from_utf8(body).unwrap();
@@ -555,13 +560,21 @@ mod tests {
let body = serialize_table(&table).unwrap();
let parsed = parse_csv(std::str::from_utf8(&body).unwrap()).unwrap();
let row = &parsed.rows[0];
assert!(matches!(decode_cell(Type::Int, &row[0]).unwrap(), CellValue::Integer(42)));
assert!(matches!(
decode_cell(Type::Int, &row[0]).unwrap(),
CellValue::Integer(42)
));
match decode_cell(Type::Real, &row[1]).unwrap() {
CellValue::Real(f) => assert!((f - std::f64::consts::PI).abs() < 1e-12),
other => panic!("got {other:?}"),
}
assert!(matches!(decode_cell(Type::Bool, &row[2]).unwrap(), CellValue::Integer(1)));
assert!(matches!(decode_cell(Type::Blob, &row[3]).unwrap(), CellValue::Blob(b) if b == b"hi"));
assert!(matches!(
decode_cell(Type::Bool, &row[2]).unwrap(),
CellValue::Integer(1)
));
assert!(
matches!(decode_cell(Type::Blob, &row[3]).unwrap(), CellValue::Blob(b) if b == b"hi")
);
}
#[test]
@@ -572,7 +585,10 @@ mod tests {
#[test]
fn decode_cell_reports_friendly_error_for_bad_int() {
let cell = RawCell { content: "abc".to_string(), was_quoted: false };
let cell = RawCell {
content: "abc".to_string(),
was_quoted: false,
};
let err = decode_cell(Type::Int, &cell).expect_err("must error");
assert!(err.contains("integer"));
assert!(err.contains("abc"));
+33 -18
View File
@@ -108,10 +108,7 @@ pub(super) fn read_recent_sources(
});
}
};
let mut sources: Vec<String> = body
.lines()
.filter_map(parse_record_source)
.collect();
let mut sources: Vec<String> = body.lines().filter_map(parse_record_source).collect();
if sources.len() > max_n {
let skip = sources.len() - max_n;
sources.drain(0..skip);
@@ -187,12 +184,26 @@ fn looks_like_iso8601(s: &str) -> bool {
return false;
}
let digit = |i: usize| b[i].is_ascii_digit();
digit(0) && digit(1) && digit(2) && digit(3) && b[4] == b'-'
&& digit(5) && digit(6) && b[7] == b'-'
&& digit(8) && digit(9) && b[10] == b'T'
&& digit(11) && digit(12) && b[13] == b':'
&& digit(14) && digit(15) && b[16] == b':'
&& digit(17) && digit(18) && b[19] == b'Z'
digit(0)
&& digit(1)
&& digit(2)
&& digit(3)
&& b[4] == b'-'
&& digit(5)
&& digit(6)
&& b[7] == b'-'
&& digit(8)
&& digit(9)
&& b[10] == b'T'
&& digit(11)
&& digit(12)
&& b[13] == b':'
&& digit(14)
&& digit(15)
&& b[16] == b':'
&& digit(17)
&& digit(18)
&& b[19] == b'Z'
}
fn unescape_command(s: &str) -> String {
@@ -321,10 +332,8 @@ mod tests {
#[test]
fn parse_journal_record_ok_extracts_unescaped_source() {
let rec = parse_journal_record(
"2026-05-24T10:00:00Z|ok|create table T with pk id(int)",
)
.expect("valid ok journal record");
let rec = parse_journal_record("2026-05-24T10:00:00Z|ok|create table T with pk id(int)")
.expect("valid ok journal record");
assert!(rec.status_is_ok);
assert_eq!(rec.source, "create table T with pk id(int)");
}
@@ -370,8 +379,8 @@ mod tests {
fn parse_journal_record_preserves_pipe_in_source() {
// `|` is not escaped by the writer (it's a valid SQL char);
// `splitn(3, '|')` keeps everything after the second `|`.
let rec = parse_journal_record("2026-05-24T10:00:00Z|ok|select 'a|b' from t")
.expect("ok record");
let rec =
parse_journal_record("2026-05-24T10:00:00Z|ok|select 'a|b' from t").expect("ok record");
assert_eq!(rec.source, "select 'a|b' from t");
}
@@ -406,7 +415,10 @@ mod tests {
#[test]
fn iso8601_known_seconds() {
assert_eq!(iso8601_from_unix_secs(0), "1970-01-01T00:00:00Z");
assert_eq!(iso8601_from_unix_secs(1_778_112_000), "2026-05-07T00:00:00Z");
assert_eq!(
iso8601_from_unix_secs(1_778_112_000),
"2026-05-07T00:00:00Z"
);
}
#[test]
@@ -437,7 +449,10 @@ mod tests {
.collect();
std::fs::write(&path, body).unwrap();
let got = read_recent_sources(&path, 3).unwrap();
assert_eq!(got, vec!["cmd7".to_string(), "cmd8".to_string(), "cmd9".to_string()]);
assert_eq!(
got,
vec!["cmd7".to_string(), "cmd8".to_string(), "cmd9".to_string()]
);
}
#[test]
+37 -43
View File
@@ -82,7 +82,10 @@ impl Default for MigratorRegistry {
#[derive(Debug)]
pub enum MigrateError {
VersionParse(String),
NewerThanSupported { file: u32, latest: u32 },
NewerThanSupported {
file: u32,
latest: u32,
},
NoMigratorForVersion(u32),
StepFailed {
from: u32,
@@ -108,10 +111,9 @@ impl std::fmt::Display for MigrateError {
file = file,
latest = latest,
)),
Self::NoMigratorForVersion(v) => f.write_str(&crate::t!(
"persistence.migrate.no_migrator",
version = v,
)),
Self::NoMigratorForVersion(v) => {
f.write_str(&crate::t!("persistence.migrate.no_migrator", version = v,))
}
Self::StepFailed { from, to, source } => f.write_str(&crate::t!(
"persistence.migrate.step_failed",
from = from,
@@ -192,8 +194,11 @@ pub fn migrate_to_latest(
// Write the .bak before any transformation runs so a
// mid-migration crash leaves the original recoverable.
let bak_path =
project_path.join(format!("{}.v{}.bak", crate::project::PROJECT_YAML, file_version));
let bak_path = project_path.join(format!(
"{}.v{}.bak",
crate::project::PROJECT_YAML,
file_version
));
std::fs::write(&bak_path, body).map_err(|source| MigrateError::Io {
path: bak_path.clone(),
source,
@@ -214,8 +219,8 @@ pub fn migrate_to_latest(
// Sanity: the new body must declare the next version.
// If a migrator forgets to bump, we'd loop endlessly
// through the chain — catch it here.
let advertised = read_version(&next_body)
.map_err(|e| MigrateError::BadOutput(e.to_string()))?;
let advertised =
read_version(&next_body).map_err(|e| MigrateError::BadOutput(e.to_string()))?;
if advertised != v + 1 {
return Err(MigrateError::BadOutput(format!(
"v{v}→v{} migrator left version field at {advertised}",
@@ -281,9 +286,8 @@ fn read_version(body: &str) -> Result<u32, MigrateError> {
struct VersionOnly {
version: u32,
}
let v: VersionOnly = serde_norway::from_str(body).map_err(|e| {
MigrateError::VersionParse(e.to_string())
})?;
let v: VersionOnly =
serde_norway::from_str(body).map_err(|e| MigrateError::VersionParse(e.to_string()))?;
Ok(v.version)
}
@@ -309,12 +313,8 @@ mod tests {
#[test]
fn no_migration_runs_when_body_already_latest() {
let tmp = tempdir();
let outcome = migrate_to_latest(
&v1_body(),
&MigratorRegistry::production(),
tmp.path(),
)
.unwrap();
let outcome =
migrate_to_latest(&v1_body(), &MigratorRegistry::production(), tmp.path()).unwrap();
assert_eq!(outcome.body, v1_body());
assert_eq!(outcome.migrated_from, None);
// No .bak written when nothing migrated.
@@ -328,7 +328,13 @@ mod tests {
let err = migrate_to_latest(body, &MigratorRegistry::production(), Path::new("/tmp"))
.expect_err("must reject");
assert!(
matches!(err, MigrateError::NewerThanSupported { file: 99, latest: 1 }),
matches!(
err,
MigrateError::NewerThanSupported {
file: 99,
latest: 1
}
),
"got: {err:?}",
);
}
@@ -366,12 +372,7 @@ mod tests {
#[test]
fn migrate_runs_chain_and_writes_bak() {
let tmp = tempdir();
let outcome = migrate_to_latest(
&v1_body(),
&registry_with_v1_to_v2(),
tmp.path(),
)
.unwrap();
let outcome = migrate_to_latest(&v1_body(), &registry_with_v1_to_v2(), tmp.path()).unwrap();
assert_eq!(outcome.migrated_from, Some(1));
assert!(outcome.body.contains("version: 2"));
let bak = tmp.path().join("project.yaml.v1.bak");
@@ -396,11 +397,8 @@ mod tests {
let tmp = tempdir();
let yaml_path = tmp.path().join("project.yaml");
std::fs::write(&yaml_path, v1_body()).unwrap();
let outcome = ensure_project_yaml_migrated(
tmp.path(),
&MigratorRegistry::production(),
)
.unwrap();
let outcome =
ensure_project_yaml_migrated(tmp.path(), &MigratorRegistry::production()).unwrap();
assert_eq!(outcome.migrated_from, None);
// File unchanged.
let on_disk = std::fs::read_to_string(&yaml_path).unwrap();
@@ -413,36 +411,32 @@ mod tests {
let tmp = tempdir();
let yaml_path = tmp.path().join("project.yaml");
std::fs::write(&yaml_path, v1_body()).unwrap();
let outcome = ensure_project_yaml_migrated(
tmp.path(),
&registry_with_v1_to_v2(),
)
.unwrap();
let outcome = ensure_project_yaml_migrated(tmp.path(), &registry_with_v1_to_v2()).unwrap();
assert_eq!(outcome.migrated_from, Some(1));
let on_disk = std::fs::read_to_string(&yaml_path).unwrap();
assert!(on_disk.contains("version: 2"), "got: {on_disk}");
let bak = tmp.path().join("project.yaml.v1.bak");
assert!(bak.exists());
assert!(std::fs::read_to_string(&bak).unwrap().contains("version: 1"));
assert!(
std::fs::read_to_string(&bak)
.unwrap()
.contains("version: 1")
);
}
#[test]
fn ensure_yaml_migrated_handles_missing_yaml() {
let tmp = tempdir();
// No project.yaml exists.
let outcome = ensure_project_yaml_migrated(
tmp.path(),
&MigratorRegistry::production(),
)
.unwrap();
let outcome =
ensure_project_yaml_migrated(tmp.path(), &MigratorRegistry::production()).unwrap();
assert_eq!(outcome.migrated_from, None);
assert!(outcome.body.is_empty());
}
#[test]
fn migrator_that_returns_internal_error_propagates() {
let bad: MigrateFn =
|_| Err(MigrateError::VersionParse("simulated".to_string()));
let bad: MigrateFn = |_| Err(MigrateError::VersionParse("simulated".to_string()));
let registry = MigratorRegistry {
migrators: vec![bad],
};
+19 -19
View File
@@ -368,12 +368,11 @@ impl Persistence {
path: data_dir.clone(),
source,
})?;
let body =
csv_io::serialize_table(table).map_err(|message| PersistenceError::Encode {
kind: "CSV",
path: data_dir.join(format!("{}.csv", table.name)),
message,
})?;
let body = csv_io::serialize_table(table).map_err(|message| PersistenceError::Encode {
kind: "CSV",
path: data_dir.join(format!("{}.csv", table.name)),
message,
})?;
atomic_write(&data_dir.join(format!("{}.csv", table.name)), &body)
}
@@ -406,11 +405,8 @@ impl Persistence {
) -> Result<(), PersistenceError> {
let path = self.project_path.join(HISTORY_LOG);
let status = history::status_token(history::STATUS_OK, advanced);
let line = history::format_record_with_status(
command_text,
history::utc_iso8601_now(),
&status,
);
let line =
history::format_record_with_status(command_text, history::utc_iso8601_now(), &status);
debug!(
len = command_text.len(),
advanced, "persist: append ok record to history.log"
@@ -432,11 +428,8 @@ impl Persistence {
) -> Result<(), PersistenceError> {
let path = self.project_path.join(HISTORY_LOG);
let status = history::status_token(history::STATUS_ERR, advanced);
let line = history::format_record_with_status(
command_text,
history::utc_iso8601_now(),
&status,
);
let line =
history::format_record_with_status(command_text, history::utc_iso8601_now(), &status);
debug!(
len = command_text.len(),
advanced, "persist: append err record to history.log"
@@ -531,8 +524,14 @@ mod tests {
#[test]
fn extension_with_tmp_appends_to_existing_extension() {
assert_eq!(extension_with_tmp(Path::new("a/b/project.yaml")), "yaml.tmp");
assert_eq!(extension_with_tmp(Path::new("a/b/Customers.csv")), "csv.tmp");
assert_eq!(
extension_with_tmp(Path::new("a/b/project.yaml")),
"yaml.tmp"
);
assert_eq!(
extension_with_tmp(Path::new("a/b/Customers.csv")),
"csv.tmp"
);
assert_eq!(extension_with_tmp(Path::new("a/b/lockfile")), "tmp");
}
@@ -600,7 +599,8 @@ mod tests {
fn append_history_creates_and_appends() {
let dir = tempdir();
let p = Persistence::new(dir.path().to_path_buf());
p.append_history("create table Foo with pk id(serial)", false).unwrap();
p.append_history("create table Foo with pk id(serial)", false)
.unwrap();
p.append_history("insert into Foo (1)", false).unwrap();
let body = fs::read_to_string(dir.path().join(HISTORY_LOG)).unwrap();
let lines: Vec<&str> = body.trim_end().lines().collect();
+117 -35
View File
@@ -261,7 +261,10 @@ fn needs_quoting(s: &str) -> bool {
}
// Scalar text that looks like a YAML keyword needs quoting
// even if every character is safe.
if matches!(s, "true" | "false" | "null" | "~" | "yes" | "no" | "on" | "off") {
if matches!(
s,
"true" | "false" | "null" | "~" | "yes" | "no" | "on" | "off"
) {
return true;
}
s.chars().any(|c| !is_safe_yaml_char(c))
@@ -287,13 +290,14 @@ pub(crate) fn parse_schema(body: &str) -> Result<SchemaSnapshot, YamlError> {
for t in raw.tables {
let mut columns: Vec<ColumnSchema> = Vec::with_capacity(t.columns.len());
for c in t.columns {
let user_type = c.user_type.parse::<Type>().map_err(|_| {
YamlError::UnknownType {
let user_type = c
.user_type
.parse::<Type>()
.map_err(|_| YamlError::UnknownType {
table: t.name.clone(),
column: c.name.clone(),
raw: c.user_type.clone(),
}
})?;
})?;
columns.push(ColumnSchema {
name: c.name,
user_type,
@@ -308,7 +312,11 @@ pub(crate) fn parse_schema(body: &str) -> Result<SchemaSnapshot, YamlError> {
primary_key: t.primary_key,
columns,
unique_constraints: t.unique_constraints,
check_constraints: t.check_constraints.into_iter().map(TableCheck::from).collect(),
check_constraints: t
.check_constraints
.into_iter()
.map(TableCheck::from)
.collect(),
});
}
let mut relationships: Vec<RelationshipSchema> = Vec::with_capacity(raw.relationships.len());
@@ -381,10 +389,7 @@ pub(crate) enum YamlError {
impl std::fmt::Display for YamlError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Syntax(msg) => f.write_str(&crate::t!(
"persistence.yaml.syntax",
detail = msg,
)),
Self::Syntax(msg) => f.write_str(&crate::t!("persistence.yaml.syntax", detail = msg,)),
Self::UnsupportedVersion(v) => f.write_str(&crate::t!(
"persistence.yaml.unsupported_version",
version = v,
@@ -395,10 +400,9 @@ impl std::fmt::Display for YamlError {
column = column,
raw = raw,
)),
Self::UnknownAction(raw) => f.write_str(&crate::t!(
"persistence.yaml.unknown_action",
raw = raw,
)),
Self::UnknownAction(raw) => {
f.write_str(&crate::t!("persistence.yaml.unknown_action", raw = raw,))
}
}
}
}
@@ -545,8 +549,22 @@ mod tests {
name: "Customers".to_string(),
primary_key: vec!["id".to_string()],
columns: vec![
ColumnSchema { name: "id".to_string(), user_type: Type::Serial, unique: false, not_null: false, default: None, check: None },
ColumnSchema { name: "Name".to_string(), user_type: Type::Text, unique: false, not_null: false, default: None, check: None },
ColumnSchema {
name: "id".to_string(),
user_type: Type::Serial,
unique: false,
not_null: false,
default: None,
check: None,
},
ColumnSchema {
name: "Name".to_string(),
user_type: Type::Text,
unique: false,
not_null: false,
default: None,
check: None,
},
],
unique_constraints: Vec::new(),
check_constraints: Vec::new(),
@@ -555,8 +573,22 @@ mod tests {
name: "Orders".to_string(),
primary_key: vec!["id".to_string()],
columns: vec![
ColumnSchema { name: "id".to_string(), user_type: Type::Serial, unique: false, not_null: false, default: None, check: None },
ColumnSchema { name: "CustId".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None, check: None },
ColumnSchema {
name: "id".to_string(),
user_type: Type::Serial,
unique: false,
not_null: false,
default: None,
check: None,
},
ColumnSchema {
name: "CustId".to_string(),
user_type: Type::Int,
unique: false,
not_null: false,
default: None,
check: None,
},
],
unique_constraints: Vec::new(),
check_constraints: Vec::new(),
@@ -798,15 +830,33 @@ indexes:
name: "T".to_string(),
primary_key: vec![],
columns: vec![
ColumnSchema { name: "a".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None, check: None },
ColumnSchema { name: "b".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None, check: None },
ColumnSchema { name: "c".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None, check: None },
ColumnSchema {
name: "a".to_string(),
user_type: Type::Int,
unique: false,
not_null: false,
default: None,
check: None,
},
ColumnSchema {
name: "b".to_string(),
user_type: Type::Int,
unique: false,
not_null: false,
default: None,
check: None,
},
ColumnSchema {
name: "c".to_string(),
user_type: Type::Int,
unique: false,
not_null: false,
default: None,
check: None,
},
],
unique_constraints: vec![vec!["a".to_string(), "b".to_string()]],
check_constraints: vec![
TableCheck::unnamed("a < b"),
TableCheck::unnamed("b < c"),
],
check_constraints: vec![TableCheck::unnamed("a < b"), TableCheck::unnamed("b < c")],
}],
relationships: vec![],
indexes: vec![],
@@ -830,12 +880,29 @@ indexes:
name: "T".to_string(),
primary_key: vec!["id".to_string()],
columns: vec![
ColumnSchema { name: "id".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None, check: None },
ColumnSchema { name: "qty".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None, check: None },
ColumnSchema {
name: "id".to_string(),
user_type: Type::Int,
unique: false,
not_null: false,
default: None,
check: None,
},
ColumnSchema {
name: "qty".to_string(),
user_type: Type::Int,
unique: false,
not_null: false,
default: None,
check: None,
},
],
unique_constraints: vec![],
check_constraints: vec![
TableCheck { name: Some("qty_positive".to_string()), expr: "qty >= 0".to_string() },
TableCheck {
name: Some("qty_positive".to_string()),
expr: "qty >= 0".to_string(),
},
TableCheck::unnamed("qty < 1000"),
],
}],
@@ -844,7 +911,10 @@ indexes:
};
let body = serialize_schema(&snap);
let parsed = parse_schema(&body).expect("parse schema");
assert_eq!(parsed, snap, "named + unnamed table-CHECKs survive the yaml round-trip");
assert_eq!(
parsed, snap,
"named + unnamed table-CHECKs survive the yaml round-trip"
);
}
#[test]
@@ -968,8 +1038,22 @@ relationships:
name: "Items".to_string(),
primary_key: vec!["a".to_string(), "b".to_string()],
columns: vec![
ColumnSchema { name: "a".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None, check: None },
ColumnSchema { name: "b".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None, check: None },
ColumnSchema {
name: "a".to_string(),
user_type: Type::Int,
unique: false,
not_null: false,
default: None,
check: None,
},
ColumnSchema {
name: "b".to_string(),
user_type: Type::Int,
unique: false,
not_null: false,
default: None,
check: None,
},
],
unique_constraints: Vec::new(),
check_constraints: Vec::new(),
@@ -1019,12 +1103,10 @@ relationships:
let absent = "version: 1\nproject:\n created_at: x\ntables: []\n";
assert_eq!(parse_stored_mode(absent), None);
let explicit_simple =
"version: 1\nproject:\n created_at: x\n mode: simple\ntables: []\n";
let explicit_simple = "version: 1\nproject:\n created_at: x\n mode: simple\ntables: []\n";
assert_eq!(parse_stored_mode(explicit_simple), Some(Mode::Simple));
let advanced =
"version: 1\nproject:\n created_at: x\n mode: advanced\ntables: []\n";
let advanced = "version: 1\nproject:\n created_at: x\n mode: advanced\ntables: []\n";
assert_eq!(parse_stored_mode(advanced), Some(Mode::Advanced));
}
+8 -2
View File
@@ -170,7 +170,10 @@ fn local_hostname() -> String {
/// Uses `sysinfo` to query the OS process table.
fn pid_is_alive(pid: u32) -> bool {
let mut sys = System::new();
sys.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[Pid::from_u32(pid)]), true);
sys.refresh_processes(
sysinfo::ProcessesToUpdate::Some(&[Pid::from_u32(pid)]),
true,
);
sys.process(Pid::from_u32(pid)).is_some()
}
@@ -211,7 +214,10 @@ mod tests {
// The first lock writes our own PID; a second attempt
// should refuse because the PID is alive on this host.
let err = Lock::acquire(dir.path()).expect_err("should refuse second acquisition");
assert!(matches!(err, LockError::AlreadyHeld { .. }), "unexpected: {err:?}");
assert!(
matches!(err, LockError::AlreadyHeld { .. }),
"unexpected: {err:?}"
);
}
#[test]
+28 -29
View File
@@ -78,10 +78,7 @@ pub fn read_last_project(data_root: &Path) -> std::io::Result<Option<PathBuf>> {
/// a moved/deleted directory is the kind of error `--resume`
/// is supposed to surface clearly, not paper over by
/// resolving symlinks at write time.
pub fn write_last_project(
data_root: &Path,
project_path: &Path,
) -> std::io::Result<()> {
pub fn write_last_project(data_root: &Path, project_path: &Path) -> std::io::Result<()> {
fs::create_dir_all(data_root)?;
let final_path = data_root.join(LAST_PROJECT_FILE);
let tmp_path = data_root.join(format!("{LAST_PROJECT_FILE}.tmp"));
@@ -108,9 +105,8 @@ pub fn resolve_data_root(override_dir: Option<&Path>) -> Result<PathBuf, Project
if let Some(p) = override_dir {
return Ok(p.to_path_buf());
}
let dirs = ProjectDirs::from("", "", "rdbms-playground").ok_or(
ProjectError::DataRootUnavailable,
)?;
let dirs =
ProjectDirs::from("", "", "rdbms-playground").ok_or(ProjectError::DataRootUnavailable)?;
Ok(dirs.data_dir().to_path_buf())
}
@@ -255,21 +251,16 @@ pub enum ProjectError {
impl std::fmt::Display for ProjectError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::DataRootUnavailable => {
f.write_str(&crate::t!("project.data_root_unavailable"))
Self::DataRootUnavailable => f.write_str(&crate::t!("project.data_root_unavailable")),
Self::PathNotFound(p) => {
f.write_str(&crate::t!("project.path_not_found", path = p.display(),))
}
Self::NotAProject(p) => {
f.write_str(&crate::t!("project.not_a_project", path = p.display(),))
}
Self::AlreadyExists(p) => {
f.write_str(&crate::t!("project.already_exists", path = p.display(),))
}
Self::PathNotFound(p) => f.write_str(&crate::t!(
"project.path_not_found",
path = p.display(),
)),
Self::NotAProject(p) => f.write_str(&crate::t!(
"project.not_a_project",
path = p.display(),
)),
Self::AlreadyExists(p) => f.write_str(&crate::t!(
"project.already_exists",
path = p.display(),
)),
Self::Io { path, source } => f.write_str(&crate::t!(
"project.io",
path = path.display(),
@@ -609,11 +600,10 @@ pub fn safely_delete_temp_project(
// 2. Canonicalize for the containment check. We do this
// only after the symlink-at-top check so we can't be
// tricked by a top-level symlink.
let project_canon =
fs::canonicalize(project_path).map_err(|source| SafeDeleteError::Io {
path: project_path.to_path_buf(),
source,
})?;
let project_canon = fs::canonicalize(project_path).map_err(|source| SafeDeleteError::Io {
path: project_path.to_path_buf(),
source,
})?;
// 3. Containment: canonical path must be inside the
// canonical data-root projects dir.
@@ -848,7 +838,10 @@ mod tests {
assert!(gi.contains("/playground.db"));
assert!(gi.contains("/.rdbms-playground.lock"));
assert!(gi.contains("/.snapshots/"), "undo ring should be ignored");
assert!(!gi.contains("history.log"), "history.log should NOT be ignored");
assert!(
!gi.contains("history.log"),
"history.log should NOT be ignored"
);
}
#[test]
@@ -890,7 +883,10 @@ mod tests {
let target = tmp.path().join("MyProject");
fs::create_dir(&target).unwrap();
let err = Project::create_named(&target).expect_err("must refuse");
assert!(matches!(err, ProjectError::AlreadyExists(_)), "got: {err:?}");
assert!(
matches!(err, ProjectError::AlreadyExists(_)),
"got: {err:?}"
);
}
#[test]
@@ -962,7 +958,10 @@ mod tests {
)
.unwrap();
let read_back = read_last_project(tmp.path()).unwrap();
assert_eq!(read_back, Some(std::path::PathBuf::from("/tmp/some/project")));
assert_eq!(
read_back,
Some(std::path::PathBuf::from("/tmp/some/project"))
);
}
#[test]
+30 -15
View File
@@ -17,8 +17,8 @@
use std::path::Path;
use rand::seq::IndexedRandom;
use rand::Rng;
use rand::seq::IndexedRandom;
const WORDLIST: &str = include_str!("wordlist.txt");
const MAX_COLLISION_RETRIES: usize = 100;
@@ -41,10 +41,9 @@ pub enum NamingError {
impl std::fmt::Display for NamingError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::WordlistTooSmall(n) => f.write_str(&crate::t!(
"project.naming.wordlist_too_small",
count = n,
)),
Self::WordlistTooSmall(n) => {
f.write_str(&crate::t!("project.naming.wordlist_too_small", count = n,))
}
Self::TooManyCollisions(n) => f.write_str(&crate::t!(
"project.naming.too_many_collisions",
attempts = n,
@@ -189,10 +188,9 @@ impl std::fmt::Display for UserNameError {
match self {
Self::Empty => f.write_str(&crate::t!("project.user_name.empty")),
Self::LeadingDot => f.write_str(&crate::t!("project.user_name.leading_dot")),
Self::InvalidChar(c) => f.write_str(&crate::t!(
"project.user_name.invalid_char",
ch = c,
)),
Self::InvalidChar(c) => {
f.write_str(&crate::t!("project.user_name.invalid_char", ch = c,))
}
}
}
}
@@ -209,14 +207,22 @@ mod tests {
#[test]
fn wordlist_has_enough_entries() {
let pool = words();
assert!(pool.len() >= 100, "wordlist suspiciously small: {} entries", pool.len());
assert!(
pool.len() >= 100,
"wordlist suspiciously small: {} entries",
pool.len()
);
}
#[test]
fn wordlist_has_no_duplicates() {
let pool = words();
let unique: std::collections::HashSet<_> = pool.iter().collect();
assert_eq!(unique.len(), pool.len(), "wordlist contains duplicate entries");
assert_eq!(
unique.len(),
pool.len(),
"wordlist contains duplicate entries"
);
}
#[test]
@@ -290,7 +296,7 @@ mod tests {
assert!(!is_temp_dirname("MyOrders"));
assert!(!is_temp_dirname("term_planner"));
assert!(!is_temp_dirname("20260507-water-buffalo-skating")); // no marker
assert!(!is_temp_dirname("temp-project")); // no brackets
assert!(!is_temp_dirname("temp-project")); // no brackets
}
#[test]
@@ -301,9 +307,18 @@ mod tests {
assert!(validate_user_name("project.v2").is_ok());
assert_eq!(validate_user_name(""), Err(UserNameError::Empty));
assert_eq!(validate_user_name(".hidden"), Err(UserNameError::LeadingDot));
assert!(matches!(validate_user_name("a/b"), Err(UserNameError::InvalidChar('/'))));
assert!(matches!(validate_user_name("a b"), Err(UserNameError::InvalidChar(' '))));
assert_eq!(
validate_user_name(".hidden"),
Err(UserNameError::LeadingDot)
);
assert!(matches!(
validate_user_name("a/b"),
Err(UserNameError::InvalidChar('/'))
));
assert!(matches!(
validate_user_name("a b"),
Err(UserNameError::InvalidChar(' '))
));
}
fn tempdir() -> tempfile::TempDir {
+8 -2
View File
@@ -129,7 +129,10 @@ mod tests {
#[test]
fn strips_date_prefix_from_temp_project_names() {
assert_eq!(prettify("20260507-water-buffalo-skating"), "Water Buffalo Skating");
assert_eq!(
prettify("20260507-water-buffalo-skating"),
"Water Buffalo Skating"
);
}
#[test]
@@ -205,6 +208,9 @@ mod tests {
#[test]
fn handles_mixed_separators_and_case() {
assert_eq!(prettify("MyTeam_lessonPlan-2026"), "My Team Lesson Plan 2026");
assert_eq!(
prettify("MyTeam_lessonPlan-2026"),
"My Team Lesson Plan 2026"
);
}
}
+125 -149
View File
@@ -36,8 +36,8 @@ use crate::db::{
use crate::dsl::command::{
Constraint, ConstraintKind, IndexSelector, RelationshipSelector, TableConstraint,
};
use crate::dsl::{AlterTableAction, ChangeColumnMode, Command, ColumnSpec};
use crate::dsl::walker::Severity;
use crate::dsl::{AlterTableAction, ChangeColumnMode, ColumnSpec, Command};
use crate::event::AppEvent;
use crate::project::{
Project, ProjectKind, copy_project, list_projects, open_or_create, projects_dir,
@@ -130,8 +130,7 @@ pub async fn run(args: Args) -> Result<()> {
// to it for `new` (creates a temp) and `load` (lists
// projects). We can't easily recover this from the
// Project alone, so we keep it ourselves.
let data_root = resolve_data_root(args.data_dir.as_deref())
.context("resolve data root")?;
let data_root = resolve_data_root(args.data_dir.as_deref()).context("resolve data root")?;
// Resolve the initial project path: --resume reads it from
// <data-root>/last_project; otherwise an explicit positional
@@ -143,17 +142,12 @@ pub async fn run(args: Args) -> Result<()> {
// terminal so the message lands directly in the user's
// shell.
let initial_path: Option<PathBuf> = if args.resume {
match read_last_project(&data_root)
.context("read last_project")?
{
match read_last_project(&data_root).context("read last_project")? {
Some(p) if p.exists() => Some(p),
Some(p) => {
eprintln!(
"rdbms-playground: {}",
crate::t!(
"project.resume_recorded_missing",
path = p.display(),
),
crate::t!("project.resume_recorded_missing", path = p.display(),),
);
return Ok(());
}
@@ -488,19 +482,15 @@ async fn run_loop(
// Best-effort — a failure to record a failure must
// never escalate a user error into a fatal, so the
// result is logged and ignored.
if let Err(e) = crate::persistence::Persistence::new(
session.project().path().to_path_buf(),
)
.append_history_failure(&source, advanced)
if let Err(e) =
crate::persistence::Persistence::new(session.project().path().to_path_buf())
.append_history_failure(&source, advanced)
{
tracing::warn!(error = %e, "failed to journal err record (ignored)");
}
}
Action::PrepareRebuild => {
spawn_prepare_rebuild(
session.project().path().to_path_buf(),
event_tx.clone(),
);
spawn_prepare_rebuild(session.project().path().to_path_buf(), event_tx.clone());
}
Action::Rebuild { source } => {
spawn_rebuild(
@@ -671,8 +661,8 @@ async fn run_loop(
// mutually exclusive (one needs an unmodified temp, the
// other anything else).
let project_at_quit = session.project.as_ref();
let cleanup_on_quit: Option<std::path::PathBuf> = project_at_quit
.and_then(|p| p.is_unmodified_temp().then(|| p.path().to_path_buf()));
let cleanup_on_quit: Option<std::path::PathBuf> =
project_at_quit.and_then(|p| p.is_unmodified_temp().then(|| p.path().to_path_buf()));
let resume_target_on_quit: Option<std::path::PathBuf> = project_at_quit
.filter(|p| !p.is_unmodified_temp())
.map(|p| p.path().to_path_buf());
@@ -831,7 +821,10 @@ async fn perform_switch(
Some(p)
}
SwitchRequest::NewTemp => None,
SwitchRequest::Import { zip_path, as_target } => {
SwitchRequest::Import {
zip_path,
as_target,
} => {
if !zip_path.exists() {
return Err(crate::t!(
"project.import_zip_missing",
@@ -840,8 +833,7 @@ async fn perform_switch(
}
// Validate the zip up front so we don't drop the
// current project for an unimportable file.
let inspection = crate::archive::inspect_zip(zip_path)
.map_err(|e| e.to_string())?;
let inspection = crate::archive::inspect_zip(zip_path).map_err(|e| e.to_string())?;
let resolved = resolve_import_destination(
as_target.as_deref(),
&inspection.top_folder,
@@ -856,16 +848,19 @@ async fn perform_switch(
// state matches the in-memory db).
if let SwitchRequest::SaveAs { .. } = &req {
let src = session.project().path().to_path_buf();
let dst = resolved_target.as_ref().expect("SaveAs has resolved target");
let dst = resolved_target
.as_ref()
.expect("SaveAs has resolved target");
copy_project(&src, dst).map_err(|e| e.to_string())?;
}
// For Import: extract the zip into the resolved target.
// We do this *before* dropping the current project so
// a failure here leaves the user where they were.
if let SwitchRequest::Import { zip_path, .. } = &req {
let dst = resolved_target.as_ref().expect("Import has resolved target");
let inspection = crate::archive::inspect_zip(zip_path)
.map_err(|e| e.to_string())?;
let dst = resolved_target
.as_ref()
.expect("Import has resolved target");
let inspection = crate::archive::inspect_zip(zip_path).map_err(|e| e.to_string())?;
crate::archive::extract_into(zip_path, dst, &inspection.top_folder)
.map_err(|e| e.to_string())?;
}
@@ -874,10 +869,10 @@ async fn perform_switch(
// we drop it: if it was an unmodified empty temp, we
// delete its directory after the switch so the data dir
// doesn't accumulate empty scratch projects.
let outgoing_cleanup_path: Option<std::path::PathBuf> =
session.project.as_ref().and_then(|p| {
p.is_unmodified_temp().then(|| p.path().to_path_buf())
});
let outgoing_cleanup_path: Option<std::path::PathBuf> = session
.project
.as_ref()
.and_then(|p| p.is_unmodified_temp().then(|| p.path().to_path_buf()));
// Drop current project + database BEFORE opening the new
// ones, releasing the old lock and stopping the old
@@ -954,9 +949,7 @@ async fn perform_switch(
let new_database =
Database::open_with_persistence_and_undo(&db_path, persistence, undo_enabled)
.map_err(|e| e.to_string())?;
if !db_existed
&& let Err(e) = new_database.rebuild_from_text(new_path.clone(), None).await
{
if !db_existed && let Err(e) = new_database.rebuild_from_text(new_path.clone(), None).await {
return Err(e.friendly_message());
}
@@ -982,9 +975,7 @@ async fn perform_switch(
// fresh empty temp (a `new` command), which must not be
// recorded (see the gate in `run()`). Write failures are
// non-fatal.
if new_worth_recording
&& let Err(e) = write_last_project(&session.data_root, &new_path)
{
if new_worth_recording && let Err(e) = write_last_project(&session.data_root, &new_path) {
tracing::warn!(error = %e, "could not update last_project after switch");
}
@@ -1045,8 +1036,8 @@ fn spawn_export(
event_tx: mpsc::Sender<AppEvent>,
) {
// `export` app command: journalled simple (ADR-0052).
let _ = crate::persistence::Persistence::new(project_path.clone())
.append_history(&source, false);
let _ =
crate::persistence::Persistence::new(project_path.clone()).append_history(&source, false);
tokio::spawn(async move {
let outcome = tokio::task::spawn_blocking(move || {
do_export(&project_path, &project_name, &data_root, target.as_deref())
@@ -1081,9 +1072,8 @@ fn do_export(
}
None => {
std::fs::create_dir_all(data_root).map_err(|e| e.to_string())?;
let (filename, _) =
crate::archive::next_export_sequence(data_root, project_name)
.map_err(|e| e.to_string())?;
let (filename, _) = crate::archive::next_export_sequence(data_root, project_name)
.map_err(|e| e.to_string())?;
data_root.join(filename)
}
};
@@ -1143,10 +1133,7 @@ async fn seed_initial_tables(database: &Database, event_tx: &mpsc::Sender<AppEve
/// no completion. Called wherever `TablesRefreshed` is sent
/// today; the schema cache lives on the App and feeds Tab
/// completion for identifier slots.
async fn refresh_schema_cache(
database: &Database,
event_tx: &mpsc::Sender<AppEvent>,
) {
async fn refresh_schema_cache(database: &Database, event_tx: &mpsc::Sender<AppEvent>) {
let cache = build_schema_cache(database).await;
let _ = event_tx.send(AppEvent::SchemaCacheRefreshed(cache)).await;
// ADR-0046 DB2: full relationship records for the sidebar panel.
@@ -1234,10 +1221,7 @@ async fn build_schema_cache(database: &Database) -> crate::completion::SchemaCac
/// summary that the confirmation modal shows. Runs off the
/// event loop so the brief I/O doesn't stall input handling
/// even on slow filesystems.
fn spawn_prepare_rebuild(
project_path: std::path::PathBuf,
event_tx: mpsc::Sender<AppEvent>,
) {
fn spawn_prepare_rebuild(project_path: std::path::PathBuf, event_tx: mpsc::Sender<AppEvent>) {
tokio::spawn(async move {
let summary = match summarize_project(&project_path) {
Ok(s) => s,
@@ -1317,9 +1301,7 @@ fn spawn_rebuild(
}
let summary = summarize_project(&project_path)
.unwrap_or_else(|_| "rebuild complete".to_string());
let _ = event_tx
.send(AppEvent::RebuildSucceeded { summary })
.await;
let _ = event_tx.send(AppEvent::RebuildSucceeded { summary }).await;
// Refresh the table list so the items panel
// reflects whatever the rebuild produced.
if let Ok(tables) = database.list_tables().await {
@@ -1462,12 +1444,8 @@ fn spawn_dsl_dispatch(
}
let event = match outcome {
Ok(CommandOutcome::Schema(description)) => {
let schema_echo = build_schema_echo(
&command,
submission_mode,
description.as_ref(),
&lookups,
);
let schema_echo =
build_schema_echo(&command, submission_mode, description.as_ref(), &lookups);
AppEvent::DslSucceeded {
command: command.clone(),
description,
@@ -1484,12 +1462,10 @@ fn spawn_dsl_dispatch(
Ok(CommandOutcome::SchemaDropIndexSkipped) => AppEvent::DslDropIndexSkipped {
command: command.clone(),
},
Ok(CommandOutcome::SchemaCreateIndexSkipped(name)) => {
AppEvent::DslCreateIndexSkipped {
command: command.clone(),
name,
}
}
Ok(CommandOutcome::SchemaCreateIndexSkipped(name)) => AppEvent::DslCreateIndexSkipped {
command: command.clone(),
name,
},
Ok(CommandOutcome::Query(data)) => {
// ADR-0038: `show data` is the only DSL-form query that
// echoes; its limited form orders by the table's primary
@@ -1507,12 +1483,10 @@ fn spawn_dsl_dispatch(
command: command.clone(),
lines,
},
Ok(CommandOutcome::ShowRelationship(data)) => {
AppEvent::DslShowRelationshipSucceeded {
command: command.clone(),
data: data.map(|b| *b),
}
}
Ok(CommandOutcome::ShowRelationship(data)) => AppEvent::DslShowRelationshipSucceeded {
command: command.clone(),
data: data.map(|b| *b),
},
Ok(CommandOutcome::QueryPlan(plan)) => AppEvent::DslExplainSucceeded {
command: command.clone(),
plan,
@@ -1568,11 +1542,8 @@ fn spawn_dsl_dispatch(
// the covering indexes the rebuild removed — Bucket B
// category 2, ADR-0038 §7 Slice 2b). Non-cascade falls
// through to the pre-execution `echo` from `echo_for`.
let cascade_echo = build_drop_column_cascade_echo(
&command,
submission_mode,
&result,
);
let cascade_echo =
build_drop_column_cascade_echo(&command, submission_mode, &result);
AppEvent::DslDropColumnSucceeded {
command: command.clone(),
result,
@@ -1931,12 +1902,14 @@ fn build_schema_echo(
)])
}
}
Command::DropRelationship { .. } => lookups
.drop_relationship
.as_ref()
.map(|(name, child_table)| {
vec![crate::echo::render_drop_relationship(name, child_table)]
}),
Command::DropRelationship { .. } => {
lookups
.drop_relationship
.as_ref()
.map(|(name, child_table)| {
vec![crate::echo::render_drop_relationship(name, child_table)]
})
}
// `create m:n relationship` (ADR-0045): the resolved junction
// columns/FKs only exist on the post-exec description, so the
// teaching echo is rendered from it (not `command_to_sql`).
@@ -1946,14 +1919,29 @@ fn build_schema_echo(
.iter()
.filter_map(|c| c.user_type.map(|ty| (c.name.clone(), ty)))
.collect();
let primary_key: Vec<String> =
desc.columns.iter().filter(|c| c.primary_key).map(|c| c.name.clone()).collect();
let primary_key: Vec<String> = desc
.columns
.iter()
.filter(|c| c.primary_key)
.map(|c| c.name.clone())
.collect();
let foreign_keys: Vec<(Vec<String>, String, Vec<String>)> = desc
.outbound_relationships
.iter()
.map(|r| (r.local_columns.clone(), r.other_table.clone(), r.other_columns.clone()))
.map(|r| {
(
r.local_columns.clone(),
r.other_table.clone(),
r.other_columns.clone(),
)
})
.collect();
vec![crate::echo::render_create_m2n(&desc.name, &columns, &primary_key, &foreign_keys)]
vec![crate::echo::render_create_m2n(
&desc.name,
&columns,
&primary_key,
&foreign_keys,
)]
}),
// Everything else (Bucket A pure-Command, plus the no-echo Bucket C
// variants like `Sql*` / `ShowTable`) routes through the existing
@@ -2103,10 +2091,7 @@ async fn enrich_unique_violation(
facts
}
fn enrich_not_null_violation(
command: &Command,
message: &str,
) -> crate::friendly::FailureContext {
fn enrich_not_null_violation(command: &Command, message: &str) -> crate::friendly::FailureContext {
let mut facts = crate::friendly::FailureContext::default();
let Some((table, column)) = parse_qualified_target(message) else {
return facts;
@@ -2133,9 +2118,7 @@ async fn enrich_fk_violation(
// schema-aware lookup so natural-order multi-value
// INSERT (which `user_value_for_column` alone can't
// resolve) gets handled too.
let Ok((outbound, _)) =
database.read_relationships(table.clone()).await
else {
let Ok((outbound, _)) = database.read_relationships(table.clone()).await else {
return facts;
};
facts.table = Some(table.clone());
@@ -2173,8 +2156,7 @@ async fn enrich_fk_violation(
// children reference). Check inbound as a fallback.
if facts.parent_table.is_none()
&& matches!(command, Command::Update { .. })
&& let Ok((_, inbound)) =
database.read_relationships(table.clone()).await
&& let Ok((_, inbound)) = database.read_relationships(table.clone()).await
&& let Some(rel) = inbound.first()
{
facts.child_table = Some(rel.other_table.clone());
@@ -2184,9 +2166,7 @@ async fn enrich_fk_violation(
// Parent-side: inbound FK lookup. Surface a child
// table that still references the row(s) being
// deleted.
let Ok((_, inbound)) =
database.read_relationships(table.clone()).await
else {
let Ok((_, inbound)) = database.read_relationships(table.clone()).await else {
return facts;
};
facts.table = Some(table.clone());
@@ -2271,10 +2251,7 @@ async fn user_value_for_column_with_schema(
..
} = command
{
let desc = database
.describe_table(table.to_string())
.await
.ok()?;
let desc = database.describe_table(table.to_string()).await.ok()?;
// Build the natural-order column list the same way
// `do_insert` does: filter out serial / shortid columns
// because the engine auto-fills them and the user's
@@ -2285,8 +2262,7 @@ async fn user_value_for_column_with_schema(
.filter(|c| {
!matches!(
c.user_type,
Some(crate::dsl::Type::Serial)
| Some(crate::dsl::Type::ShortId)
Some(crate::dsl::Type::Serial) | Some(crate::dsl::Type::ShortId)
)
})
.map(|c| c.name.as_str())
@@ -2310,10 +2286,7 @@ async fn user_value_for_column_with_schema(
&& listed_columns.is_empty()
&& literal_rows.len() == 1
{
let desc = database
.describe_table(table.to_string())
.await
.ok()?;
let desc = database.describe_table(table.to_string()).await.ok()?;
let idx = desc.columns.iter().position(|c| c.name == column)?;
return literal_rows[0].get(idx).cloned().flatten();
}
@@ -2323,16 +2296,12 @@ async fn user_value_for_column_with_schema(
/// Render a `DataResult` as a `DiagnosticTable` for the
/// friendly-error layer's bordered renderer (ADR-0019 §7,
/// reusing ADR-0017 §7's renderer).
fn diagnostic_from_data_result(
data: &DataResult,
) -> crate::friendly::DiagnosticTable {
use crate::output_render::{numeric_alignment_for, Alignment};
fn diagnostic_from_data_result(data: &DataResult) -> crate::friendly::DiagnosticTable {
use crate::output_render::{Alignment, numeric_alignment_for};
let alignments: Vec<Alignment> = data
.column_types
.iter()
.map(|t| {
t.map_or(Alignment::Left, numeric_alignment_for)
})
.map(|t| t.map_or(Alignment::Left, numeric_alignment_for))
.collect();
let rows: Vec<Vec<String>> = data
.rows
@@ -2543,9 +2512,7 @@ pub async fn run_replay(
// command, which was skipped above) — report it with the line
// number and stop.
let schema = build_schema_cache(database).await;
let command = match crate::dsl::parser::parse_command_with_schema(
&command_text, &schema,
) {
let command = match crate::dsl::parser::parse_command_with_schema(&command_text, &schema) {
Ok(c) => c,
Err(e) => {
events.push(AppEvent::ReplayFailed {
@@ -2566,8 +2533,7 @@ pub async fn run_replay(
// Retain a clone for failure enrichment (the command is moved into
// dispatch). ADR-0035 Amendment 1, F2 follow-up.
let command_for_ctx = command.clone();
let outcome =
execute_command_typed(database, command, command_text.clone()).await;
let outcome = execute_command_typed(database, command, command_text.clone()).await;
match outcome {
Ok(_) => {
// ADR-0052: journal the replayed line at the dispatch
@@ -2873,7 +2839,10 @@ async fn execute_command_typed(
.drop_constraint(table, column, ConstraintKind::NotNull, src)
.await
.map(|d| CommandOutcome::Schema(Some(d))),
AlterTableAction::SetColumnDefault { column, default_sql } => database
AlterTableAction::SetColumnDefault {
column,
default_sql,
} => database
.set_column_default(table, column, default_sql, src)
.await
.map(|d| CommandOutcome::Schema(Some(d))),
@@ -2989,10 +2958,7 @@ async fn execute_command_typed(
// A SQL `SELECT` (advanced mode; ADR-0030 §6, ADR-0031).
// The grammar walker has already validated `sql` is in
// the supported subset; the worker runs it as text.
Command::Select { sql } => database
.run_select(sql)
.await
.map(CommandOutcome::Query),
Command::Select { sql } => database.run_select(sql).await.map(CommandOutcome::Query),
// A SQL `INSERT` (advanced mode; ADR-0033 §1). Grammar-as-
// text: the worker runs the validated `sql` and re-persists
// the parsed `target_table`'s CSV. Reuses the DSL insert
@@ -3112,12 +3078,9 @@ fn setup_terminal() -> Result<Terminal<CrosstermBackend<io::Stdout>>> {
Ok(terminal)
}
fn teardown_terminal(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
) -> Result<()> {
fn teardown_terminal(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
disable_raw_mode().context("disable raw mode")?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)
.context("leave alternate screen")?;
execute!(terminal.backend_mut(), LeaveAlternateScreen).context("leave alternate screen")?;
terminal.show_cursor().context("show cursor")?;
Ok(())
}
@@ -3257,7 +3220,9 @@ mod tests {
// Limited → ORDER BY the resolved primary key.
assert_eq!(
super::build_show_data_echo(&db, &limited, EffectiveMode::AdvancedPersistent).await,
Some(vec!["SELECT * FROM Customers ORDER BY id LIMIT 5".to_string()]),
Some(vec![
"SELECT * FROM Customers ORDER BY id LIMIT 5".to_string()
]),
);
// Simple mode → silent, gated before any lookup.
assert_eq!(
@@ -3288,10 +3253,10 @@ mod tests {
async fn bucket_b_resolved_name_echoes_against_real_worker() {
use crate::app::EffectiveMode;
use crate::db::Database;
use crate::dsl::Command;
use crate::dsl::ReferentialAction;
use crate::dsl::command::{ColumnSpec, IndexSelector, RelationshipSelector};
use crate::dsl::types::Type;
use crate::dsl::Command;
let db = Database::open(":memory:").expect("open in-memory");
db.create_table(
@@ -3319,7 +3284,12 @@ mod tests {
// --- add index (auto-named) ----------------------------------
let desc_after_add_index = db
.add_index(None, "Customers".to_string(), vec!["Email".to_string()], None)
.add_index(
None,
"Customers".to_string(),
vec!["Email".to_string()],
None,
)
.await
.expect("add index");
let add_idx_cmd = Command::AddIndex {
@@ -3439,7 +3409,10 @@ mod tests {
.await;
assert_eq!(
endpoints_lookups.drop_relationship,
Some(("Customers_id_to_Orders_CustId".to_string(), "Orders".to_string())),
Some((
"Customers_id_to_Orders_CustId".to_string(),
"Orders".to_string()
)),
"endpoints selector resolves name via child describe",
);
@@ -3454,7 +3427,10 @@ mod tests {
.await;
assert_eq!(
named_lookups.drop_relationship,
Some(("Customers_id_to_Orders_CustId".to_string(), "Orders".to_string())),
Some((
"Customers_id_to_Orders_CustId".to_string(),
"Orders".to_string()
)),
"named selector scans user tables to find the child",
);
@@ -3487,10 +3463,10 @@ mod tests {
async fn bucket_b_multi_statement_echoes_against_real_worker() {
use crate::app::EffectiveMode;
use crate::db::Database;
use crate::dsl::Command;
use crate::dsl::ReferentialAction;
use crate::dsl::command::ColumnSpec;
use crate::dsl::types::Type;
use crate::dsl::Command;
// --- drop column --cascade -----------------------------------
let db = Database::open(":memory:").expect("open in-memory");
@@ -3505,9 +3481,14 @@ mod tests {
)
.await
.expect("create Customers");
db.add_index(None, "Customers".to_string(), vec!["Email".to_string()], None)
.await
.expect("index Email");
db.add_index(
None,
"Customers".to_string(),
vec!["Email".to_string()],
None,
)
.await
.expect("index Email");
let drop_cmd = Command::DropColumn {
table: "Customers".to_string(),
@@ -3531,12 +3512,8 @@ mod tests {
);
// Simple mode → silent.
assert!(
super::build_drop_column_cascade_echo(
&drop_cmd,
EffectiveMode::Simple,
&drop_result,
)
.is_none(),
super::build_drop_column_cascade_echo(&drop_cmd, EffectiveMode::Simple, &drop_result,)
.is_none(),
);
// --- add relationship --create-fk (column newly created) ----
@@ -3673,11 +3650,11 @@ mod tests {
// switch (an unmodified temp would be cleaned up, taking its
// project.yaml with it). Without the unload persist the
// outgoing skeleton carries no `mode:` → `None`.
use super::{handle_project_switch, Session, SwitchRequest};
use super::{Session, SwitchRequest, handle_project_switch};
use crate::db::Database;
use crate::mode::Mode;
use crate::persistence::Persistence;
use crate::project::{projects_dir, Project};
use crate::project::{Project, projects_dir};
use tokio::sync::mpsc;
let data_root = tempfile::tempdir().unwrap();
@@ -3686,8 +3663,7 @@ mod tests {
let outgoing_path = projects.join("Outgoing");
let outgoing = Project::create_named(&outgoing_path).unwrap();
let db_path = outgoing.db_path();
let persistence =
Persistence::new(outgoing.path().to_path_buf()).with_mode(Mode::Advanced);
let persistence = Persistence::new(outgoing.path().to_path_buf()).with_mode(Mode::Advanced);
let database =
Database::open_with_persistence_and_undo(&db_path, persistence, true).unwrap();
let mut session = Session {
+7 -3
View File
@@ -18,7 +18,11 @@ pub fn parse_in_check_values(check: &str, column: &str) -> Option<Vec<String>> {
return None;
}
let values = extract_quoted_list(&check[paren_open..])?;
if values.is_empty() { None } else { Some(values) }
if values.is_empty() {
None
} else {
Some(values)
}
}
const fn is_ident_byte(b: u8) -> bool {
@@ -45,8 +49,8 @@ fn find_in_paren(check: &str) -> Option<(usize, usize)> {
i += 1;
continue;
}
let is_in = (b == b'i' || b == b'I')
&& bytes.get(i + 1).is_some_and(|n| *n == b'n' || *n == b'N');
let is_in =
(b == b'i' || b == b'I') && bytes.get(i + 1).is_some_and(|n| *n == b'n' || *n == b'N');
if is_in {
let before_ok = i == 0 || !is_ident_byte(bytes[i - 1]);
let after = i + 2;
+127 -42
View File
@@ -81,17 +81,22 @@ pub fn generate_value(generator: &Generator, ty: Type, rng: &mut SeedRng) -> Val
Generator::CurrencyAmount => currency_amount(ty, rng),
Generator::Age => Value::Number(rng.random_range(18..=80).to_string()),
Generator::SmallInt => Value::Number(rng.random_range(1..=100).to_string()),
Generator::YearRecent => {
Value::Number(rng.random_range((REF_YEAR - YEAR_RECENT_SPAN)..=REF_YEAR).to_string())
}
Generator::YearRecent => Value::Number(
rng.random_range((REF_YEAR - YEAR_RECENT_SPAN)..=REF_YEAR)
.to_string(),
),
Generator::YearBirth => Value::Number(
rng.random_range((REF_YEAR - YEAR_BIRTH_MAX_AGE)..=(REF_YEAR - YEAR_BIRTH_MIN_AGE))
.to_string(),
),
Generator::DateRecent => Value::Text(format_date(random_past_date(rng, 0, RECENT_WINDOW_DAYS))),
Generator::DateAdult => {
Value::Text(format_date(random_past_date(rng, ADULT_MIN_DAYS, ADULT_MAX_DAYS)))
Generator::DateRecent => {
Value::Text(format_date(random_past_date(rng, 0, RECENT_WINDOW_DAYS)))
}
Generator::DateAdult => Value::Text(format_date(random_past_date(
rng,
ADULT_MIN_DAYS,
ADULT_MAX_DAYS,
))),
Generator::DateTimeRecent => Value::Text(random_recent_datetime(rng)),
Generator::Boolean => Value::Bool(rng.random_range(0..2) == 1),
Generator::PickFrom(values) if !values.is_empty() => {
@@ -232,8 +237,7 @@ fn random_datetime_between(
} else {
rng.random_range(hi_s..=lo_s)
};
let dt = chrono::DateTime::from_timestamp(secs, 0)
.map_or(lo, |d| d.naive_utc());
let dt = chrono::DateTime::from_timestamp(secs, 0).map_or(lo, |d| d.naive_utc());
dt.format("%Y-%m-%dT%H:%M:%S").to_string()
}
@@ -294,20 +298,35 @@ fn currency_amount(ty: Type, rng: &mut SeedRng) -> Value {
// — the hand-rolled `product` generator (D9) —
const PRODUCT_ADJECTIVES: &[&str] = &[
"Sleek", "Rustic", "Ergonomic", "Handcrafted", "Refined", "Modern",
"Vintage", "Compact", "Premium", "Lightweight", "Durable", "Elegant",
"Sturdy", "Smooth", "Gorgeous", "Intelligent", "Practical", "Awesome",
"Incredible", "Recycled",
"Sleek",
"Rustic",
"Ergonomic",
"Handcrafted",
"Refined",
"Modern",
"Vintage",
"Compact",
"Premium",
"Lightweight",
"Durable",
"Elegant",
"Sturdy",
"Smooth",
"Gorgeous",
"Intelligent",
"Practical",
"Awesome",
"Incredible",
"Recycled",
];
const PRODUCT_MATERIALS: &[&str] = &[
"Wooden", "Copper", "Granite", "Cotton", "Steel", "Leather", "Bamboo",
"Plastic", "Ceramic", "Glass", "Concrete", "Rubber", "Bronze", "Marble",
"Linen", "Silk", "Aluminum", "Wool", "Gold", "Carbon",
"Wooden", "Copper", "Granite", "Cotton", "Steel", "Leather", "Bamboo", "Plastic", "Ceramic",
"Glass", "Concrete", "Rubber", "Bronze", "Marble", "Linen", "Silk", "Aluminum", "Wool", "Gold",
"Carbon",
];
const PRODUCT_NOUNS: &[&str] = &[
"Chair", "Lamp", "Table", "Bottle", "Backpack", "Keyboard", "Mug",
"Shoes", "Jacket", "Watch", "Wallet", "Bench", "Hat", "Gloves",
"Towel", "Ball", "Bike", "Knife", "Pillow", "Blanket",
"Chair", "Lamp", "Table", "Bottle", "Backpack", "Keyboard", "Mug", "Shoes", "Jacket", "Watch",
"Wallet", "Bench", "Hat", "Gloves", "Towel", "Ball", "Bike", "Knife", "Pillow", "Blanket",
];
fn product_name(rng: &mut SeedRng) -> String {
@@ -396,7 +415,9 @@ mod tests {
] {
let v = gen_once(&generator, Type::Text, 3);
match v {
Value::Text(s) => assert!(!s.trim().is_empty(), "{generator:?} produced empty text"),
Value::Text(s) => {
assert!(!s.trim().is_empty(), "{generator:?} produced empty text")
}
other => panic!("{generator:?} produced non-text {other:?}"),
}
}
@@ -405,18 +426,25 @@ mod tests {
#[test]
fn email_looks_like_an_email() {
let v = gen_once(&Generator::Email, Type::Text, 11);
let Value::Text(s) = v else { panic!("not text") };
let Value::Text(s) = v else {
panic!("not text")
};
assert!(s.contains('@'), "email should contain @: {s}");
}
#[test]
fn product_name_is_three_capitalised_words() {
let v = gen_once(&Generator::ProductName, Type::Text, 99);
let Value::Text(s) = v else { panic!("not text") };
let Value::Text(s) = v else {
panic!("not text")
};
let words: Vec<&str> = s.split(' ').collect();
assert_eq!(words.len(), 3, "product name should be 3 words: {s}");
for w in words {
assert!(w.chars().next().unwrap().is_ascii_uppercase(), "word `{w}` not capitalised");
assert!(
w.chars().next().unwrap().is_ascii_uppercase(),
"word `{w}` not capitalised"
);
}
}
@@ -429,9 +457,14 @@ mod tests {
let latest = reference_date();
for _ in 0..200 {
let v = generate_value(&Generator::DateRecent, Type::Date, &mut rng);
let Value::Text(s) = v else { panic!("date not text") };
let Value::Text(s) = v else {
panic!("date not text")
};
let d = NaiveDate::parse_from_str(&s, "%Y-%m-%d").expect("valid ISO date");
assert!(d >= earliest && d <= latest, "date {d} outside recent window");
assert!(
d >= earliest && d <= latest,
"date {d} outside recent window"
);
}
}
@@ -446,7 +479,9 @@ mod tests {
.unwrap();
for _ in 0..200 {
let v = generate_value(&Generator::DateAdult, Type::Date, &mut rng);
let Value::Text(s) = v else { panic!("date not text") };
let Value::Text(s) = v else {
panic!("date not text")
};
let d = NaiveDate::parse_from_str(&s, "%Y-%m-%d").expect("valid ISO date");
assert!(d >= earliest && d <= latest, "dob {d} outside adult window");
}
@@ -455,7 +490,9 @@ mod tests {
#[test]
fn datetime_is_iso_shaped() {
let v = gen_once(&Generator::DateTimeRecent, Type::DateTime, 5);
let Value::Text(s) = v else { panic!("not text") };
let Value::Text(s) = v else {
panic!("not text")
};
assert!(s.contains('T'), "datetime needs a T separator: {s}");
// Parses as a naive datetime.
chrono::NaiveDateTime::parse_from_str(&s, "%Y-%m-%dT%H:%M:%S")
@@ -467,11 +504,17 @@ mod tests {
let Value::Number(int_amt) = gen_once(&Generator::CurrencyAmount, Type::Int, 4) else {
panic!("not a number")
};
assert!(!int_amt.contains('.'), "int currency should be whole: {int_amt}");
assert!(
!int_amt.contains('.'),
"int currency should be whole: {int_amt}"
);
let Value::Number(dec_amt) = gen_once(&Generator::CurrencyAmount, Type::Decimal, 4) else {
panic!("not a number")
};
assert!(dec_amt.contains('.'), "decimal currency should have cents: {dec_amt}");
assert!(
dec_amt.contains('.'),
"decimal currency should have cents: {dec_amt}"
);
}
#[test]
@@ -494,7 +537,10 @@ mod tests {
let Value::Text(s) = generate_value(&generator, Type::Text, &mut rng) else {
panic!("not text")
};
assert!(matches!(s.as_str(), "active" | "closed"), "unexpected pick {s}");
assert!(
matches!(s.as_str(), "active" | "closed"),
"unexpected pick {s}"
);
}
}
@@ -503,7 +549,10 @@ mod tests {
let generator = Generator::PickFrom(vec!["1".into(), "2".into(), "3".into()]);
let mut rng = make_rng(Some(6));
let v = generate_value(&generator, Type::Int, &mut rng);
assert!(matches!(v, Value::Number(_)), "numeric pick should be a Number: {v:?}");
assert!(
matches!(v, Value::Number(_)),
"numeric pick should be a Number: {v:?}"
);
}
#[test]
@@ -517,7 +566,10 @@ mod tests {
panic!("YearRecent must be a Number")
};
let n: i32 = s.parse().unwrap();
assert!((1950..=2025).contains(&n), "YearRecent {n} out of [1950,2025]");
assert!(
(1950..=2025).contains(&n),
"YearRecent {n} out of [1950,2025]"
);
}
for _ in 0..300 {
let Value::Number(s) = generate_value(&Generator::YearBirth, Type::Int, &mut rng)
@@ -525,7 +577,10 @@ mod tests {
panic!("YearBirth must be a Number")
};
let n: i32 = s.parse().unwrap();
assert!((1945..=2007).contains(&n), "YearBirth {n} out of [1945,2007]");
assert!(
(1945..=2007).contains(&n),
"YearBirth {n} out of [1945,2007]"
);
}
}
@@ -543,7 +598,10 @@ mod tests {
#[test]
fn int_range_stays_within_inclusive_bounds() {
let g = Generator::Range { low: "10".into(), high: "20".into() };
let g = Generator::Range {
low: "10".into(),
high: "20".into(),
};
let mut rng = make_rng(Some(5));
for _ in 0..200 {
let Value::Number(s) = generate_value(&g, Type::Int, &mut rng) else {
@@ -556,7 +614,10 @@ mod tests {
#[test]
fn real_range_stays_within_bounds_and_has_cents() {
let g = Generator::Range { low: "1.0".into(), high: "9.0".into() };
let g = Generator::Range {
low: "1.0".into(),
high: "9.0".into(),
};
let mut rng = make_rng(Some(5));
for _ in 0..200 {
let Value::Number(s) = generate_value(&g, Type::Real, &mut rng) else {
@@ -588,13 +649,19 @@ mod tests {
#[test]
fn reversed_bounds_are_tolerated() {
let g = Generator::Range { low: "20".into(), high: "10".into() };
let g = Generator::Range {
low: "20".into(),
high: "10".into(),
};
let mut rng = make_rng(Some(1));
let Value::Number(s) = generate_value(&g, Type::Int, &mut rng) else {
panic!("number")
};
let n: i64 = s.parse().unwrap();
assert!((10..=20).contains(&n), "reversed bounds still produce in-range: {n}");
assert!(
(10..=20).contains(&n),
"reversed bounds still produce in-range: {n}"
);
}
#[test]
@@ -603,7 +670,10 @@ mod tests {
assert!(range_bounds_reason(Type::Int, "1", "10").is_none());
assert!(range_bounds_reason(Type::Real, "1.5", "9.9").is_none());
assert!(range_bounds_reason(Type::Date, "2023-01-01", "2024-01-01").is_none());
assert!(range_bounds_reason(Type::DateTime, "2023-01-01T00:00:00", "2024-01-01T00:00:00").is_none());
assert!(
range_bounds_reason(Type::DateTime, "2023-01-01T00:00:00", "2024-01-01T00:00:00")
.is_none()
);
// Non-numeric bound on a numeric column.
assert!(range_bounds_reason(Type::Int, "abc", "10").is_some());
// A range on a text column is meaningless.
@@ -623,14 +693,29 @@ mod tests {
#[test]
fn generic_fallback_matches_each_type() {
let mut rng = make_rng(Some(0));
assert!(matches!(generate_value(&Generator::Generic, Type::Text, &mut rng), Value::Text(_)));
assert!(matches!(generate_value(&Generator::Generic, Type::Int, &mut rng), Value::Number(_)));
assert!(matches!(generate_value(&Generator::Generic, Type::Bool, &mut rng), Value::Bool(_)));
assert!(matches!(generate_value(&Generator::Generic, Type::Blob, &mut rng), Value::Null));
assert!(matches!(
generate_value(&Generator::Generic, Type::Text, &mut rng),
Value::Text(_)
));
assert!(matches!(
generate_value(&Generator::Generic, Type::Int, &mut rng),
Value::Number(_)
));
assert!(matches!(
generate_value(&Generator::Generic, Type::Bool, &mut rng),
Value::Bool(_)
));
assert!(matches!(
generate_value(&Generator::Generic, Type::Blob, &mut rng),
Value::Null
));
// shortid fallback is a valid base58 id.
let Value::Text(sid) = generate_value(&Generator::Generic, Type::ShortId, &mut rng) else {
panic!("shortid not text")
};
assert!(crate::dsl::shortid::validate(&sid).is_ok(), "invalid shortid {sid}");
assert!(
crate::dsl::shortid::validate(&sid).is_ok(),
"invalid shortid {sid}"
);
}
}
+181 -44
View File
@@ -63,8 +63,7 @@ pub fn is_enum_ish(name: &str) -> bool {
// `rating` / `stars` were never here. `status` stays — it is
// deliberately left to the advisory (no built-in set).
const ENUM_TOKENS: &[&str] = &[
"role", "status", "state", "type", "kind", "category", "level",
"tier", "stage", "gender",
"role", "status", "state", "type", "kind", "category", "level", "tier", "stage", "gender",
];
let toks = tokens(name);
toks.iter().any(|t| ENUM_TOKENS.contains(&t.as_str()))
@@ -81,9 +80,7 @@ fn match_name_generator(table: &str, toks: &[String], ty: Type) -> Option<Genera
if text && (has_any(toks, &["fname", "firstname"]) || has_seq(toks, "first", "name")) {
return Some(Generator::FirstName);
}
if text
&& (has_any(toks, &["lname", "lastname", "surname"]) || has_seq(toks, "last", "name"))
{
if text && (has_any(toks, &["lname", "lastname", "surname"]) || has_seq(toks, "last", "name")) {
return Some(Generator::LastName);
}
if text && (has_any(toks, &["username", "login", "handle"]) || has_seq(toks, "user", "name")) {
@@ -116,7 +113,10 @@ fn match_name_generator(table: &str, toks: &[String], ty: Type) -> Option<Genera
// `province` / explicit `state_name`/`state_abbr` → a real state name.
// Bare `state` is left to enum-ish (it usually means status), so we
// require `province` or a `state` token paired with name/abbr.
if text && (has_token(toks, "province") || (has_token(toks, "state") && has_any(toks, &["name", "abbr"]))) {
if text
&& (has_token(toks, "province")
|| (has_token(toks, "state") && has_any(toks, &["name", "abbr"])))
{
return Some(Generator::StateName);
}
if text && has_any(toks, &["street", "address", "addr"]) {
@@ -127,7 +127,12 @@ fn match_name_generator(table: &str, toks: &[String], ty: Type) -> Option<Genera
}
// — Organisation / job —
if text && has_any(toks, &["company", "employer", "org", "organization", "organisation"]) {
if text
&& has_any(
toks,
&["company", "employer", "org", "organization", "organisation"],
)
{
return Some(Generator::Company);
}
if text && has_any(toks, &["job", "position", "profession", "occupation"]) {
@@ -135,7 +140,21 @@ fn match_name_generator(table: &str, toks: &[String], ty: Type) -> Option<Genera
}
// — Free text —
if text && has_any(toks, &["description", "bio", "notes", "note", "summary", "comment", "comments", "about"]) {
if text
&& has_any(
toks,
&[
"description",
"bio",
"notes",
"note",
"summary",
"comment",
"comments",
"about",
],
)
{
return Some(Generator::Sentence);
}
if text && has_any(toks, &["url", "website", "homepage", "link"]) {
@@ -146,7 +165,14 @@ fn match_name_generator(table: &str, toks: &[String], ty: Type) -> Option<Genera
}
// — Numeric —
if numeric && has_any(toks, &["price", "amount", "cost", "salary", "balance", "total", "fee", "revenue"]) {
if numeric
&& has_any(
toks,
&[
"price", "amount", "cost", "salary", "balance", "total", "fee", "revenue",
],
)
{
return Some(Generator::CurrencyAmount);
}
if numeric && has_token(toks, "age") {
@@ -233,18 +259,50 @@ fn match_name_generator(table: &str, toks: &[String], ty: Type) -> Option<Genera
fn name_by_table_context(table: &str) -> Generator {
let toks = tokens(table);
const PRODUCTY: &[&str] = &[
"product", "products", "item", "items", "good", "goods",
"merchandise", "catalog", "catalogue", "inventory", "sku", "skus",
"product",
"products",
"item",
"items",
"good",
"goods",
"merchandise",
"catalog",
"catalogue",
"inventory",
"sku",
"skus",
];
const COMPANYISH: &[&str] = &[
"company", "companies", "vendor", "vendors", "supplier",
"suppliers", "manufacturer", "manufacturers", "brand", "brands",
"organization", "organisation",
"company",
"companies",
"vendor",
"vendors",
"supplier",
"suppliers",
"manufacturer",
"manufacturers",
"brand",
"brands",
"organization",
"organisation",
];
const PERSONISH: &[&str] = &[
"user", "users", "customer", "customers", "person", "people",
"employee", "employees", "member", "members", "contact",
"contacts", "author", "authors", "student", "students",
"user",
"users",
"customer",
"customers",
"person",
"people",
"employee",
"employees",
"member",
"members",
"contact",
"contacts",
"author",
"authors",
"student",
"students",
];
if has_any(&toks, PRODUCTY) {
Generator::ProductName
@@ -264,9 +322,8 @@ fn name_by_table_context(table: &str) -> Generator {
/// before this guard; this catches structural names.
fn is_name_false_positive(toks: &[String]) -> bool {
const NON_PERSON: &[&str] = &[
"file", "table", "host", "domain", "field", "class", "tag",
"event", "path", "col", "column", "db", "schema", "index", "key",
"page", "node", "type",
"file", "table", "host", "domain", "field", "class", "tag", "event", "path", "col",
"column", "db", "schema", "index", "key", "page", "node", "type",
];
has_any(toks, NON_PERSON) && has_any(toks, &["name", "title"])
}
@@ -357,9 +414,18 @@ mod tests {
#[test]
fn person_name_fields_map_to_name_generators() {
assert_eq!(choose("users", "first_name", Type::Text), Generator::FirstName);
assert_eq!(choose("users", "firstName", Type::Text), Generator::FirstName);
assert_eq!(choose("users", "last_name", Type::Text), Generator::LastName);
assert_eq!(
choose("users", "first_name", Type::Text),
Generator::FirstName
);
assert_eq!(
choose("users", "firstName", Type::Text),
Generator::FirstName
);
assert_eq!(
choose("users", "last_name", Type::Text),
Generator::LastName
);
assert_eq!(choose("users", "surname", Type::Text), Generator::LastName);
}
@@ -368,9 +434,15 @@ mod tests {
assert_eq!(choose("users", "email", Type::Text), Generator::Email);
assert_eq!(choose("users", "work_email", Type::Text), Generator::Email);
assert_eq!(choose("users", "username", Type::Text), Generator::Username);
assert_eq!(choose("users", "user_name", Type::Text), Generator::Username);
assert_eq!(
choose("users", "user_name", Type::Text),
Generator::Username
);
assert_eq!(choose("users", "phone", Type::Text), Generator::Phone);
assert_eq!(choose("accounts", "password", Type::Text), Generator::Password);
assert_eq!(
choose("accounts", "password", Type::Text),
Generator::Password
);
}
#[test]
@@ -386,7 +458,10 @@ mod tests {
#[test]
fn bare_name_uses_table_context() {
// D11 — the same column name resolves differently by table.
assert_eq!(choose("products", "name", Type::Text), Generator::ProductName);
assert_eq!(
choose("products", "name", Type::Text),
Generator::ProductName
);
assert_eq!(choose("items", "title", Type::Text), Generator::ProductName);
assert_eq!(choose("users", "name", Type::Text), Generator::FullName);
assert_eq!(choose("customers", "name", Type::Text), Generator::FullName);
@@ -399,7 +474,10 @@ mod tests {
fn name_false_positives_do_not_become_person_names() {
// These must NOT resolve to a person/product name.
assert_ne!(choose("files", "filename", Type::Text), Generator::FullName);
assert_ne!(choose("meta", "table_name", Type::Text), Generator::FullName);
assert_ne!(
choose("meta", "table_name", Type::Text),
Generator::FullName
);
// They fall through to a generic / non-person generator.
assert_eq!(choose("files", "filename", Type::Text), Generator::Generic);
}
@@ -408,7 +486,10 @@ mod tests {
fn numeric_name_heuristics_are_type_gated() {
// `price` on a numeric column → currency; on text → falls through.
assert_eq!(choose("p", "price", Type::Int), Generator::CurrencyAmount);
assert_eq!(choose("p", "price", Type::Decimal), Generator::CurrencyAmount);
assert_eq!(
choose("p", "price", Type::Decimal),
Generator::CurrencyAmount
);
assert_eq!(choose("p", "price", Type::Text), Generator::Generic);
assert_eq!(choose("u", "age", Type::Int), Generator::Age);
assert_eq!(choose("o", "quantity", Type::Int), Generator::SmallInt);
@@ -425,8 +506,14 @@ mod tests {
fn temporal_fields_are_bounded_and_type_gated() {
assert_eq!(choose("u", "dob", Type::Date), Generator::DateAdult);
assert_eq!(choose("o", "order_date", Type::Date), Generator::DateRecent);
assert_eq!(choose("o", "created_at", Type::DateTime), Generator::DateTimeRecent);
assert_eq!(choose("o", "timestamp", Type::DateTime), Generator::DateTimeRecent);
assert_eq!(
choose("o", "created_at", Type::DateTime),
Generator::DateTimeRecent
);
assert_eq!(
choose("o", "timestamp", Type::DateTime),
Generator::DateTimeRecent
);
// Wrong type → not a date generator.
assert_eq!(choose("o", "order_date", Type::Int), Generator::Generic);
}
@@ -440,17 +527,32 @@ mod tests {
#[test]
fn identifier_family_is_unique_sequential() {
assert_eq!(choose("t", "code", Type::Text), Generator::IdentitySequential);
assert_eq!(choose("t", "sku", Type::Text), Generator::IdentitySequential);
assert_eq!(choose("t", "order_number", Type::Int), Generator::IdentitySequential);
assert_eq!(choose("t", "external_id", Type::Int), Generator::IdentitySequential);
assert_eq!(
choose("t", "code", Type::Text),
Generator::IdentitySequential
);
assert_eq!(
choose("t", "sku", Type::Text),
Generator::IdentitySequential
);
assert_eq!(
choose("t", "order_number", Type::Int),
Generator::IdentitySequential
);
assert_eq!(
choose("t", "external_id", Type::Int),
Generator::IdentitySequential
);
}
#[test]
fn foreign_key_columns_defer_to_executor() {
let mut spec = ColumnSpec::plain("user_id", Type::Int);
spec.is_foreign_key = true;
assert_eq!(choose_generator("orders", &spec), Generator::ForeignKeySample);
assert_eq!(
choose_generator("orders", &spec),
Generator::ForeignKeySample
);
}
#[test]
@@ -481,13 +583,28 @@ mod tests {
fn year_like_int_columns_map_to_bounded_years() {
// Issue #33: `int`-gated year heuristics. `birth`/`born`/`dob`
// years pick the birth window; the rest a recent window.
assert_eq!(choose("authors", "birth_year", Type::Int), Generator::YearBirth);
assert_eq!(choose("authors", "birthYear", Type::Int), Generator::YearBirth);
assert_eq!(
choose("authors", "birth_year", Type::Int),
Generator::YearBirth
);
assert_eq!(
choose("authors", "birthYear", Type::Int),
Generator::YearBirth
);
assert_eq!(choose("u", "year_born", Type::Int), Generator::YearBirth);
assert_eq!(choose("books", "year", Type::Int), Generator::YearRecent);
assert_eq!(choose("films", "release_year", Type::Int), Generator::YearRecent);
assert_eq!(choose("books", "published", Type::Int), Generator::YearRecent);
assert_eq!(choose("companies", "founded", Type::Int), Generator::YearRecent);
assert_eq!(
choose("films", "release_year", Type::Int),
Generator::YearRecent
);
assert_eq!(
choose("books", "published", Type::Int),
Generator::YearRecent
);
assert_eq!(
choose("companies", "founded", Type::Int),
Generator::YearRecent
);
// Type-gated: a text `year` is not a bounded-year int.
assert_eq!(choose("books", "year", Type::Text), Generator::Generic);
// `year_count` is a count, not a year — the quantity rule wins.
@@ -507,7 +624,12 @@ mod tests {
);
assert_eq!(
choose("bugs", "severity", Type::Text),
Generator::PickFrom(vec!["low".into(), "medium".into(), "high".into(), "critical".into()]),
Generator::PickFrom(vec![
"low".into(),
"medium".into(),
"high".into(),
"critical".into()
]),
);
assert_eq!(
choose("bugs", "severity", Type::Int),
@@ -515,11 +637,23 @@ mod tests {
);
assert_eq!(
choose("reviews", "rating", Type::Int),
Generator::PickFrom(vec!["1".into(), "2".into(), "3".into(), "4".into(), "5".into()]),
Generator::PickFrom(vec![
"1".into(),
"2".into(),
"3".into(),
"4".into(),
"5".into()
]),
);
assert_eq!(
choose("reviews", "stars", Type::Int),
Generator::PickFrom(vec!["1".into(), "2".into(), "3".into(), "4".into(), "5".into()]),
Generator::PickFrom(vec![
"1".into(),
"2".into(),
"3".into(),
"4".into(),
"5".into()
]),
);
}
@@ -552,7 +686,10 @@ mod tests {
#[test]
fn unmatched_columns_use_type_based_fallback() {
assert_eq!(choose("t", "some_freeform_field", Type::Text), Generator::Generic);
assert_eq!(
choose("t", "some_freeform_field", Type::Text),
Generator::Generic
);
}
#[test]
+5 -2
View File
@@ -32,7 +32,7 @@ mod vocabulary;
pub use check::parse_in_check_values;
pub use generators::{generate_value, range_bounds_reason};
pub use heuristics::{choose_generator, is_enum_ish};
pub use vocabulary::{generator_for_name, is_known_generator_prefix, KNOWN_GENERATORS};
pub use vocabulary::{KNOWN_GENERATORS, generator_for_name, is_known_generator_prefix};
use rand::rngs::StdRng;
use rand::{RngExt, SeedableRng};
@@ -183,7 +183,10 @@ pub enum Generator {
/// does not parse for the column type is a friendly error), so
/// [`generate_value`] only ever sees parseable bounds; a defensive
/// parse failure falls back to type-based generation.
Range { low: String, high: String },
Range {
low: String,
high: String,
},
/// Type-based fallback (D8) when no name heuristic matches.
Generic,
}
+38 -29
View File
@@ -104,15 +104,15 @@ impl Theme {
// remains restful; literals and flags get warm
// accent tones; keyword takes a cool accent tone
// distinct from the mode-banner blue.
tok_keyword: Color::Rgb(0xC7, 0x92, 0xEA), // muted purple
tok_keyword: Color::Rgb(0xC7, 0x92, 0xEA), // muted purple
tok_identifier: Color::Rgb(0x56, 0xB6, 0xC2), // cyan-teal — identifiers are the user's content, deserve a vivid distinct colour
tok_type: Color::Rgb(0xF0, 0x8F, 0xC0), // pink — types sit in the red-purple range, clearly apart from the lavender keyword and teal identifier
tok_number: Color::Rgb(0xF7, 0x8C, 0x6C), // warm orange
tok_string: Color::Rgb(0xC3, 0xE8, 0x8D), // soft green
tok_punct: Color::Rgb(0x8B, 0x90, 0x9A), // == muted
tok_flag: Color::Rgb(0xFF, 0xCB, 0x6B), // amber
tok_error: Color::Rgb(0xFF, 0x6B, 0x6B), // == error
tok_function: Color::Rgb(0x82, 0xCF, 0xFD), // sky blue — cool like keyword but bluer, clearly apart from purple keyword + teal identifier + pink type
tok_type: Color::Rgb(0xF0, 0x8F, 0xC0), // pink — types sit in the red-purple range, clearly apart from the lavender keyword and teal identifier
tok_number: Color::Rgb(0xF7, 0x8C, 0x6C), // warm orange
tok_string: Color::Rgb(0xC3, 0xE8, 0x8D), // soft green
tok_punct: Color::Rgb(0x8B, 0x90, 0x9A), // == muted
tok_flag: Color::Rgb(0xFF, 0xCB, 0x6B), // amber
tok_error: Color::Rgb(0xFF, 0x6B, 0x6B), // == error
tok_function: Color::Rgb(0x82, 0xCF, 0xFD), // sky blue — cool like keyword but bluer, clearly apart from purple keyword + teal identifier + pink type
}
}
@@ -135,15 +135,15 @@ impl Theme {
// Light-theme token palette: same intent as dark —
// identifier/punct close to fg/muted; warm tones for
// literals + flags; cool accent for keyword.
tok_keyword: Color::Rgb(0x6F, 0x42, 0xC1), // royal purple
tok_keyword: Color::Rgb(0x6F, 0x42, 0xC1), // royal purple
tok_identifier: Color::Rgb(0x0F, 0x6B, 0x76), // deep teal — same role as dark variant: identifiers stand out
tok_type: Color::Rgb(0xA8, 0x2D, 0x73), // deep magenta — red-purple, distinct from royal-purple keyword + teal identifier
tok_number: Color::Rgb(0xBC, 0x4F, 0x1F), // burnt orange
tok_string: Color::Rgb(0x22, 0x86, 0x3A), // forest green
tok_punct: Color::Rgb(0x60, 0x66, 0x73), // == muted
tok_flag: Color::Rgb(0xB0, 0x88, 0x00), // mustard
tok_error: Color::Rgb(0xC0, 0x39, 0x2B), // == error
tok_function: Color::Rgb(0x1A, 0x5F, 0xB0), // strong blue — cool like keyword but bluer, apart from royal-purple keyword + teal identifier + magenta type
tok_type: Color::Rgb(0xA8, 0x2D, 0x73), // deep magenta — red-purple, distinct from royal-purple keyword + teal identifier
tok_number: Color::Rgb(0xBC, 0x4F, 0x1F), // burnt orange
tok_string: Color::Rgb(0x22, 0x86, 0x3A), // forest green
tok_punct: Color::Rgb(0x60, 0x66, 0x73), // == muted
tok_flag: Color::Rgb(0xB0, 0x88, 0x00), // mustard
tok_error: Color::Rgb(0xC0, 0x39, 0x2B), // == error
tok_function: Color::Rgb(0x1A, 0x5F, 0xB0), // strong blue — cool like keyword but bluer, apart from royal-purple keyword + teal identifier + magenta type
}
}
@@ -192,10 +192,7 @@ mod tests {
("tok_function", t.tok_function),
("warning", t.warning),
] {
assert_ne!(
c, t.bg,
"{name} must contrast against bg in dark theme",
);
assert_ne!(c, t.bg, "{name} must contrast against bg in dark theme",);
}
}
@@ -212,24 +209,36 @@ mod tests {
("tok_function", t.tok_function),
("warning", t.warning),
] {
assert_ne!(
c, t.bg,
"{name} must contrast against bg in light theme",
);
assert_ne!(c, t.bg, "{name} must contrast against bg in light theme",);
}
}
#[test]
fn highlight_class_color_maps_each_variant() {
let t = Theme::dark();
assert_eq!(t.highlight_class_color(HighlightClass::Keyword), t.tok_keyword);
assert_eq!(t.highlight_class_color(HighlightClass::Identifier), t.tok_identifier);
assert_eq!(
t.highlight_class_color(HighlightClass::Keyword),
t.tok_keyword
);
assert_eq!(
t.highlight_class_color(HighlightClass::Identifier),
t.tok_identifier
);
assert_eq!(t.highlight_class_color(HighlightClass::Type), t.tok_type);
assert_eq!(t.highlight_class_color(HighlightClass::Number), t.tok_number);
assert_eq!(t.highlight_class_color(HighlightClass::String), t.tok_string);
assert_eq!(
t.highlight_class_color(HighlightClass::Number),
t.tok_number
);
assert_eq!(
t.highlight_class_color(HighlightClass::String),
t.tok_string
);
assert_eq!(t.highlight_class_color(HighlightClass::Punct), t.tok_punct);
assert_eq!(t.highlight_class_color(HighlightClass::Flag), t.tok_flag);
assert_eq!(t.highlight_class_color(HighlightClass::Function), t.tok_function);
assert_eq!(
t.highlight_class_color(HighlightClass::Function),
t.tok_function
);
assert_eq!(t.highlight_class_color(HighlightClass::Error), t.tok_error);
}
+18 -21
View File
@@ -87,9 +87,7 @@ pub fn static_refusal(src: Type, target: Type) -> Option<String> {
}
const fn is_in_matrix(src: Type, target: Type) -> bool {
use Type::{
Bool, Date, DateTime, Decimal, Int, Real, Serial, ShortId, Text,
};
use Type::{Bool, Date, DateTime, Decimal, Int, Real, Serial, ShortId, Text};
matches!(
(src, target),
// Always-clean transformers
@@ -130,9 +128,7 @@ pub fn transform_cell(src: Type, target: Type, value: &Value) -> CellOutcome {
if matches!(value, Value::Null) {
return CellOutcome::Clean(Value::Null);
}
use Type::{
Bool, Date, DateTime, Decimal, Int, Real, Serial, ShortId, Text,
};
use Type::{Bool, Date, DateTime, Decimal, Int, Real, Serial, ShortId, Text};
match (src, target) {
// ---- Always-clean: int / serial source ----
(Int | Serial, Real) => match value {
@@ -179,9 +175,11 @@ pub fn transform_cell(src: Type, target: Type, value: &Value) -> CellOutcome {
(Bool, Text) => match value {
// "true" / "false" matches the DSL boolean grammar
// (ADR-0014 §5), not raw 0/1 stringification.
Value::Integer(i) => CellOutcome::Clean(Value::Text(
if *i == 0 { "false".into() } else { "true".into() },
)),
Value::Integer(i) => CellOutcome::Clean(Value::Text(if *i == 0 {
"false".into()
} else {
"true".into()
})),
other => unexpected_storage("bool", other),
},
@@ -369,9 +367,7 @@ pub fn transform_cell(src: Type, target: Type, value: &Value) -> CellOutcome {
}
} else {
CellOutcome::Incompatible {
reason: format!(
"`{s}` is not a datetime in `YYYY-MM-DDTHH:MM:SS` form"
),
reason: format!("`{s}` is not a datetime in `YYYY-MM-DDTHH:MM:SS` form"),
}
}
}
@@ -450,10 +446,7 @@ fn real_to_int(r: f64) -> CellOutcome {
let discarded = r - r.trunc();
CellOutcome::Lossy {
new: Value::Integer(truncated),
reason: format!(
"truncated; would discard {}",
format_real(discarded)
),
reason: format!("truncated; would discard {}", format_real(discarded)),
}
}
}
@@ -555,9 +548,7 @@ fn format_real(r: f64) -> String {
fn unexpected_storage(label: &str, value: &Value) -> CellOutcome {
CellOutcome::Incompatible {
reason: format!(
"internal: cell stored unexpectedly for `{label}` source ({value:?})"
),
reason: format!("internal: cell stored unexpectedly for `{label}` source ({value:?})"),
}
}
@@ -638,7 +629,10 @@ mod tests {
(Type::Date, Type::Int),
(Type::ShortId, Type::Int),
] {
assert!(static_refusal(src, target).is_some(), "{src:?} -> {target:?}");
assert!(
static_refusal(src, target).is_some(),
"{src:?} -> {target:?}"
);
}
}
@@ -672,7 +666,10 @@ mod tests {
(Type::Bool, Type::Real),
];
for (s, t) in pairs {
assert_eq!(transform_cell(s, t, &Value::Null), CellOutcome::Clean(Value::Null));
assert_eq!(
transform_cell(s, t, &Value::Null),
CellOutcome::Clean(Value::Null)
);
}
}
+238 -135
View File
@@ -196,7 +196,16 @@ fn render_badge_box(label: &str, area: Rect, above: Option<Rect>, frame: &mut Fr
area.y + area.height - box_h - 1
}
};
fill_overlay_rect(Rect { x, y, width: box_w, height: box_h }, label.to_string(), frame);
fill_overlay_rect(
Rect {
x,
y,
width: box_w,
height: box_h,
},
label.to_string(),
frame,
);
}
/// A step-caption box inset one cell from the bottom-right of `area`
@@ -309,7 +318,9 @@ fn render_path_entry(
let inner_w = dialog_w.saturating_sub(4) as usize;
let prompt_lines = wrap_lines(&m.prompt, inner_w);
// Title + blank + prompt + blank + input box (1 row + borders) + blank + key hints.
let dialog_h = (prompt_lines.len() as u16).saturating_add(8).min(area.height);
let dialog_h = (prompt_lines.len() as u16)
.saturating_add(8)
.min(area.height);
let x = area.x + (area.width.saturating_sub(dialog_w)) / 2;
let y = area.y + (area.height.saturating_sub(dialog_h)) / 2;
let dialog_area = Rect {
@@ -320,9 +331,7 @@ fn render_path_entry(
};
frame.render_widget(ratatui::widgets::Clear, dialog_area);
let title_style = Style::default()
.fg(theme.fg)
.add_modifier(Modifier::BOLD);
let title_style = Style::default().fg(theme.fg).add_modifier(Modifier::BOLD);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
@@ -386,9 +395,7 @@ fn render_load_picker(
};
frame.render_widget(ratatui::widgets::Clear, dialog_area);
let title_style = Style::default()
.fg(theme.fg)
.add_modifier(Modifier::BOLD);
let title_style = Style::default().fg(theme.fg).add_modifier(Modifier::BOLD);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
@@ -411,9 +418,7 @@ fn render_load_picker(
let marker = if i == m.selected { "" } else { " " };
let temp_tag = if entry.is_temp { "[TEMP] " } else { "" };
let style = if i == m.selected {
Style::default()
.fg(theme.fg)
.add_modifier(Modifier::BOLD)
Style::default().fg(theme.fg).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.fg)
};
@@ -447,11 +452,7 @@ fn render_load_picker(
let display_input = if *cursor == input.len() {
format!("{input}{cursor_marker}")
} else {
format!(
"{}{cursor_marker}{}",
&input[..*cursor],
&input[*cursor..]
)
format!("{}{cursor_marker}{}", &input[..*cursor], &input[*cursor..])
};
text_lines.push(Line::from(format!("> {display_input}")));
text_lines.push(Line::from(""));
@@ -500,9 +501,7 @@ fn render_rebuild_confirm(summary: &str, theme: &Theme, frame: &mut Frame<'_>, a
let bg = ratatui::widgets::Clear;
frame.render_widget(bg, dialog_area);
let title_style = Style::default()
.fg(theme.fg)
.add_modifier(Modifier::BOLD);
let title_style = Style::default().fg(theme.fg).add_modifier(Modifier::BOLD);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
@@ -524,16 +523,12 @@ fn render_rebuild_confirm(summary: &str, theme: &Theme, frame: &mut Frame<'_>, a
text_lines.push(Line::from(vec![
Span::styled(
"[Y]",
Style::default()
.fg(theme.fg)
.add_modifier(Modifier::BOLD),
Style::default().fg(theme.fg).add_modifier(Modifier::BOLD),
),
Span::raw(format!(" {} ", crate::t!("shortcut.yes"))),
Span::styled(
"[N]",
Style::default()
.fg(theme.fg)
.add_modifier(Modifier::BOLD),
Style::default().fg(theme.fg).add_modifier(Modifier::BOLD),
),
Span::raw(format!(" {} ", crate::t!("shortcut.no"))),
Span::styled("Esc", Style::default().fg(theme.muted)),
@@ -578,18 +573,14 @@ where
/// dialog (issue #13): wide enough to hold the longest content
/// line on a single row, clamped to sane bounds and the available
/// area so a short insert no longer wraps on roomy terminals.
fn undo_dialog_width(
content_widths: impl IntoIterator<Item = usize>,
area_width: u16,
) -> u16 {
fn undo_dialog_width(content_widths: impl IntoIterator<Item = usize>, area_width: u16) -> u16 {
/// Floor — comfortably fits the button row plus borders.
const MIN: u16 = 34;
/// Ceiling for outlier (ultra-wide) terminals.
const MAX: u16 = 100;
let widest = content_widths.into_iter().max().unwrap_or(0);
// +4: left/right border (2) + one padding column each side (2).
let preferred =
u16::try_from(widest).unwrap_or(u16::MAX).saturating_add(4);
let preferred = u16::try_from(widest).unwrap_or(u16::MAX).saturating_add(4);
let upper = area_width.min(MAX);
let lower = MIN.min(upper);
preferred.clamp(lower, upper)
@@ -617,8 +608,7 @@ fn render_undo_confirm(
let intro_line = format!("{intro} {}", m.command);
// Local-time, human-formatted snapshot stamp (issue #13).
let when_display = format_snapshot_timestamp(&m.timestamp);
let when_line =
crate::t!("modal.undo_confirm_when", timestamp = when_display);
let when_line = crate::t!("modal.undo_confirm_when", timestamp = when_display);
let prompt = crate::t!("modal.undo_confirm_prompt");
// Reconstruct the button row purely to measure its width — the
// styled spans are built below. Keep this in sync with them.
@@ -681,9 +671,15 @@ fn render_undo_confirm(
text_lines.push(Line::from(prompt));
text_lines.push(Line::from(""));
text_lines.push(Line::from(vec![
Span::styled("[Y]", Style::default().fg(theme.fg).add_modifier(Modifier::BOLD)),
Span::styled(
"[Y]",
Style::default().fg(theme.fg).add_modifier(Modifier::BOLD),
),
Span::raw(format!(" {} ", crate::t!("shortcut.yes"))),
Span::styled("[N]", Style::default().fg(theme.fg).add_modifier(Modifier::BOLD)),
Span::styled(
"[N]",
Style::default().fg(theme.fg).add_modifier(Modifier::BOLD),
),
Span::raw(format!(" {} ", crate::t!("shortcut.no"))),
Span::styled("Esc", Style::default().fg(theme.muted)),
Span::styled(
@@ -811,17 +807,13 @@ fn clamp_wrapped(text: &str, width: usize, max_rows: usize) -> Vec<String> {
fn render_project_label(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
let label_style = Style::default().fg(theme.muted);
let value_style = Style::default()
.fg(theme.fg)
.add_modifier(Modifier::BOLD);
let value_style = Style::default().fg(theme.fg).add_modifier(Modifier::BOLD);
let bar_style = Style::default().bg(theme.bg).fg(theme.muted);
let no_project = crate::t!("status.no_project");
let display = app.project_name.as_deref().unwrap_or(no_project.as_str());
let mut spans: Vec<Span<'_>> = vec![Span::styled(
crate::t!("status.project_label"),
label_style,
)];
let mut spans: Vec<Span<'_>> =
vec![Span::styled(crate::t!("status.project_label"), label_style)];
if app.project_is_temp {
spans.push(Span::styled(
"[TEMP] ",
@@ -875,9 +867,7 @@ fn render_items_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area:
))
.title(Span::styled(
format!(" {} ", crate::t!("panel.tables_title")),
Style::default()
.fg(theme.fg)
.add_modifier(Modifier::BOLD),
Style::default().fg(theme.fg).add_modifier(Modifier::BOLD),
))
.style(Style::default().bg(theme.bg).fg(theme.fg));
@@ -918,9 +908,7 @@ fn render_items_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area:
let mut lines: Vec<Line<'_>> = Vec::new();
for name in &app.tables {
let style = if name == highlight {
Style::default()
.fg(theme.fg)
.add_modifier(Modifier::BOLD)
Style::default().fg(theme.fg).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.fg)
};
@@ -937,7 +925,9 @@ fn render_items_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area:
}
}
}
let paragraph = Paragraph::new(lines).block(block).scroll((offset as u16, 0));
let paragraph = Paragraph::new(lines)
.block(block)
.scroll((offset as u16, 0));
frame.render_widget(paragraph, area);
}
@@ -956,9 +946,7 @@ fn render_relationships_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_
))
.title(Span::styled(
format!(" {} ", crate::t!("panel.relationships_title")),
Style::default()
.fg(theme.fg)
.add_modifier(Modifier::BOLD),
Style::default().fg(theme.fg).add_modifier(Modifier::BOLD),
))
.style(Style::default().bg(theme.bg).fg(theme.fg));
@@ -992,12 +980,24 @@ fn render_relationships_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_
ellipsize(&rel.name, inner_w),
name_style,
)));
let parent = format!(" {}.{} ->", rel.parent_table, rel.parent_columns.join(", "));
lines.push(Line::from(Span::styled(ellipsize(&parent, inner_w), detail_style)));
let parent = format!(
" {}.{} ->",
rel.parent_table,
rel.parent_columns.join(", ")
);
lines.push(Line::from(Span::styled(
ellipsize(&parent, inner_w),
detail_style,
)));
let child = format!(" {}.{}", rel.child_table, rel.child_columns.join(", "));
lines.push(Line::from(Span::styled(ellipsize(&child, inner_w), detail_style)));
lines.push(Line::from(Span::styled(
ellipsize(&child, inner_w),
detail_style,
)));
}
let paragraph = Paragraph::new(lines).block(block).scroll((offset as u16, 0));
let paragraph = Paragraph::new(lines)
.block(block)
.scroll((offset as u16, 0));
frame.render_widget(paragraph, area);
}
@@ -1022,9 +1022,7 @@ fn render_output_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area
.border_style(Style::default().fg(theme.border))
.title(Span::styled(
format!(" {} ", crate::t!("panel.output_title")),
Style::default()
.fg(theme.fg)
.add_modifier(Modifier::BOLD),
Style::default().fg(theme.fg).add_modifier(Modifier::BOLD),
))
.style(Style::default().bg(theme.bg).fg(theme.fg));
@@ -1132,9 +1130,9 @@ const fn output_span_style(class: OutputStyleClass, theme: &Theme) -> Style {
OutputStyleClass::Neutral => Style::new().fg(theme.fg),
OutputStyleClass::Efficient => Style::new().fg(theme.plan_efficient),
OutputStyleClass::Expensive => Style::new().fg(theme.warning),
OutputStyleClass::AutomaticIndex => Style::new()
.fg(theme.warning)
.add_modifier(Modifier::BOLD),
OutputStyleClass::AutomaticIndex => {
Style::new().fg(theme.warning).add_modifier(Modifier::BOLD)
}
// ADR-0038 §4 / §6: de-emphasised text — the `Executing SQL:`
// prefix and every category-3 prose line (caveat + the
// existing `client_side.*` notes). `theme.muted` is the
@@ -1239,9 +1237,7 @@ fn render_output_line<'a>(line: &'a OutputLine, theme: &Theme) -> Line<'a> {
&line.text[..prefix_len],
Style::default().fg(theme.muted),
));
for run in
crate::input_render::lex_to_runs_in_mode(rest, theme, Mode::Advanced)
{
for run in crate::input_render::lex_to_runs_in_mode(rest, theme, Mode::Advanced) {
spans.push(Span::styled(
&rest[run.byte_range.0..run.byte_range.1],
run.style,
@@ -1350,9 +1346,7 @@ fn render_input_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area:
Span::raw(" "),
Span::styled(
label,
Style::default()
.fg(mode_color)
.add_modifier(Modifier::BOLD),
Style::default().fg(mode_color).add_modifier(Modifier::BOLD),
),
Span::raw(" "),
]);
@@ -1474,7 +1468,10 @@ fn render_input_one_row(
if offset > 0 {
frame.render_widget(
Paragraph::new(Span::styled("<", marker)),
Rect { width: 1, ..text_area },
Rect {
width: 1,
..text_area
},
);
}
if offset + eff < line_cols {
@@ -1536,8 +1533,16 @@ fn render_input_two_rows(
// Overflowing both rows reserves a marker column on each row's
// outer edge; otherwise both rows use their full text width.
let overflow = line_cols >= capacity;
let row0_text_w = if overflow { row0_w.saturating_sub(1) } else { row0_w };
let row1_text_w = if overflow { row1_w.saturating_sub(1) } else { row1_w };
let row0_text_w = if overflow {
row0_w.saturating_sub(1)
} else {
row0_w
};
let row1_text_w = if overflow {
row1_w.saturating_sub(1)
} else {
row1_w
};
let eff_cap = row0_text_w + row1_text_w;
let start = offset.min(len);
@@ -1552,7 +1557,11 @@ fn render_input_two_rows(
)
};
let row0_x = if overflow { text_area.x + 1 } else { text_area.x };
let row0_x = if overflow {
text_area.x + 1
} else {
text_area.x
};
frame.render_widget(
Paragraph::new(to_line(&window[..split])),
Rect {
@@ -1622,10 +1631,7 @@ fn expand_runs_to_cells(
/// Convert `StyledRun`s into ratatui `Span`s borrowed from
/// `input`. The end-of-input cursor sentinel (empty range) is
/// rendered as an inverted space.
fn runs_to_spans<'a>(
input: &'a str,
runs: &[crate::input_render::StyledRun],
) -> Vec<Span<'a>> {
fn runs_to_spans<'a>(input: &'a str, runs: &[crate::input_render::StyledRun]) -> Vec<Span<'a>> {
runs.iter()
.map(|r| {
if r.byte_range.0 == r.byte_range.1 {
@@ -1710,21 +1716,14 @@ fn resolve_hint_lines(
}
}
fn render_hint_panel(
theme: &Theme,
frame: &mut Frame<'_>,
area: Rect,
lines: Vec<Line<'static>>,
) {
fn render_hint_panel(theme: &Theme, frame: &mut Frame<'_>, area: Rect, lines: Vec<Line<'static>>) {
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.border))
.title(Span::styled(
format!(" {} ", crate::t!("panel.hint_title")),
Style::default()
.fg(theme.fg)
.add_modifier(Modifier::BOLD),
Style::default().fg(theme.fg).add_modifier(Modifier::BOLD),
))
.style(Style::default().bg(theme.bg).fg(theme.fg));
@@ -1918,9 +1917,7 @@ fn status_bar_bindings(app: &App) -> Vec<(&'static str, String)> {
}
fn render_status_bar(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
let key_style = Style::default()
.fg(theme.fg)
.add_modifier(Modifier::BOLD);
let key_style = Style::default().fg(theme.fg).add_modifier(Modifier::BOLD);
let sep_style = Style::default().fg(theme.muted);
let label_style = Style::default().fg(theme.muted);
let bar_style = Style::default().bg(theme.bg).fg(theme.muted);
@@ -2016,9 +2013,16 @@ mod tests {
};
let rendered = render_output_line(&line, &theme);
// [system] tag, then the dim prefix, then ≥1 SQL spans.
assert!(rendered.spans.len() >= 3, "tag + prefix + sql: {:?}", rendered.spans);
assert!(
rendered.spans.len() >= 3,
"tag + prefix + sql: {:?}",
rendered.spans
);
assert_eq!(rendered.spans[0].content.as_ref(), "[system] ");
assert_eq!(rendered.spans[1].content.as_ref(), crate::echo::TEACHING_ECHO_LABEL);
assert_eq!(
rendered.spans[1].content.as_ref(),
crate::echo::TEACHING_ECHO_LABEL
);
assert_eq!(
rendered.spans[1].style.fg,
Some(theme.muted),
@@ -2152,17 +2156,41 @@ mod tests {
use crate::completion::{Candidate, CandidateKind, ModeClass};
let theme = Theme::dark();
let items = vec![
Candidate { text: "table".into(), kind: CandidateKind::Keyword, mode: ModeClass::Both },
Candidate { text: "index".into(), kind: CandidateKind::Keyword, mode: ModeClass::Advanced },
Candidate { text: "relationship".into(), kind: CandidateKind::Keyword, mode: ModeClass::Simple },
Candidate {
text: "table".into(),
kind: CandidateKind::Keyword,
mode: ModeClass::Both,
},
Candidate {
text: "index".into(),
kind: CandidateKind::Keyword,
mode: ModeClass::Advanced,
},
Candidate {
text: "relationship".into(),
kind: CandidateKind::Keyword,
mode: ModeClass::Simple,
},
];
let line = render_candidate_line(&items, None, 100, &theme);
assert_eq!(line.spans[0].content.as_ref(), "table");
assert_eq!(line.spans[0].style.fg, Some(theme.tok_keyword), "Both keeps the kind colour");
assert_eq!(
line.spans[0].style.fg,
Some(theme.tok_keyword),
"Both keeps the kind colour"
);
assert_eq!(line.spans[2].content.as_ref(), "index");
assert_eq!(line.spans[2].style.fg, Some(theme.mode_advanced), "Advanced → advanced colour");
assert_eq!(
line.spans[2].style.fg,
Some(theme.mode_advanced),
"Advanced → advanced colour"
);
assert_eq!(line.spans[4].content.as_ref(), "relationship");
assert_eq!(line.spans[4].style.fg, Some(theme.mode_simple), "Simple → simple colour");
assert_eq!(
line.spans[4].style.fg,
Some(theme.mode_simple),
"Simple → simple colour"
);
}
#[test]
@@ -2173,8 +2201,16 @@ mod tests {
use crate::completion::{Candidate, CandidateKind, ModeClass};
let theme = Theme::dark();
let items = vec![
Candidate { text: "values".into(), kind: CandidateKind::Keyword, mode: ModeClass::Advanced },
Candidate { text: "select".into(), kind: CandidateKind::Keyword, mode: ModeClass::Advanced },
Candidate {
text: "values".into(),
kind: CandidateKind::Keyword,
mode: ModeClass::Advanced,
},
Candidate {
text: "select".into(),
kind: CandidateKind::Keyword,
mode: ModeClass::Advanced,
},
];
let line = render_candidate_line(&items, None, 100, &theme);
assert_eq!(
@@ -2248,7 +2284,10 @@ mod tests {
"the error body is neutral fg, not flooded red",
);
assert!(
rendered.spans[1].style.add_modifier.contains(Modifier::BOLD),
rendered.spans[1]
.style
.add_modifier
.contains(Modifier::BOLD),
"the error body is bold for weight without the red-wall readability cost",
);
}
@@ -2509,10 +2548,14 @@ mod tests {
"the tail around the cursor must be visible:\n{out}"
);
assert!(
!out.lines().any(|l| l.contains("select * from Customers where")),
!out.lines()
.any(|l| l.contains("select * from Customers where")),
"the head must be scrolled off:\n{out}"
);
assert!(out.contains('<'), "a left scroll marker signals the hidden head:\n{out}");
assert!(
out.contains('<'),
"a left scroll marker signals the hidden head:\n{out}"
);
}
#[test]
@@ -2525,9 +2568,18 @@ mod tests {
let theme = Theme::dark();
// Narrow (sidebar hidden, DB1) so the line overflows the field.
let out = render_to_string(&mut app, &theme, 60, 24);
assert!(out.contains("select * from"), "head visible at Home:\n{out}");
assert!(out.contains('>'), "a right scroll marker signals the hidden tail:\n{out}");
assert!(!out.contains("Wonderland"), "the tail must be scrolled off:\n{out}");
assert!(
out.contains("select * from"),
"head visible at Home:\n{out}"
);
assert!(
out.contains('>'),
"a right scroll marker signals the hidden tail:\n{out}"
);
assert!(
!out.contains("Wonderland"),
"the tail must be scrolled off:\n{out}"
);
}
// ---- ADR-0046 DA4: two-row input on tall terminals -----------
@@ -2569,8 +2621,14 @@ mod tests {
let theme = Theme::dark();
// Very narrow + tall: two rows, but the line exceeds both.
let out = render_to_string(&mut app, &theme, 38, 44);
assert!(out.contains("Wonderland"), "the tail/cursor stays visible:\n{out}");
assert!(out.contains('<'), "a left marker signals the hidden head:\n{out}");
assert!(
out.contains("Wonderland"),
"the tail/cursor stays visible:\n{out}"
);
assert!(
out.contains('<'),
"a left marker signals the hidden head:\n{out}"
);
}
#[test]
@@ -2644,7 +2702,10 @@ mod tests {
/// The `key` column of the strip's bindings, in order.
fn strip_keys(app: &App) -> Vec<&'static str> {
status_bar_bindings(app).into_iter().map(|(k, _)| k).collect()
status_bar_bindings(app)
.into_iter()
.map(|(k, _)| k)
.collect()
}
/// The full rendered strip text (keys + labels + separators).
@@ -2659,7 +2720,12 @@ mod tests {
fn hint_text(lines: &[Line<'_>]) -> String {
lines
.iter()
.map(|l| l.spans.iter().map(|s| s.content.clone()).collect::<String>())
.map(|l| {
l.spans
.iter()
.map(|s| s.content.clone())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
@@ -2686,10 +2752,7 @@ mod tests {
fn strip_sidebar_focus_state_is_pane_scroll_input() {
let mut app = App::new();
app.nav_focus = NavFocus::SidebarTables;
assert_eq!(
strip_keys(&app),
vec!["Ctrl-O", "↑↓/PgUp/PgDn", "Esc"],
);
assert_eq!(strip_keys(&app), vec!["Ctrl-O", "↑↓/PgUp/PgDn", "Esc"],);
// ...and the relationships sidebar is the same state.
app.nav_focus = NavFocus::SidebarRelationships;
assert_eq!(strip_keys(&app), vec!["Ctrl-O", "↑↓/PgUp/PgDn", "Esc"]);
@@ -2802,11 +2865,7 @@ mod tests {
assert_eq!(two, vec!["alpha beta", "gamma delta"]);
// > max rows: clamp to max, last row ends with an ellipsis,
// and every row stays within the width.
let many = clamp_wrapped(
"alpha beta gamma delta epsilon zeta eta theta iota",
11,
3,
);
let many = clamp_wrapped("alpha beta gamma delta epsilon zeta eta theta iota", 11, 3);
assert_eq!(many.len(), 3);
assert!(many[2].ends_with('…'), "last row ellipsized: {many:?}");
for row in &many {
@@ -2931,9 +2990,18 @@ mod tests {
app.output.push_back(err);
let out = render_to_string(&mut app, &Theme::dark(), 100, 20);
assert!(out.contains("running: drop table Orders"), "pending keeps running::\n{out}");
assert!(out.contains("create table T with pk ✓"), "ok shows ✓:\n{out}");
assert!(out.contains("insert into T values (1) ✗"), "err shows ✗:\n{out}");
assert!(
out.contains("running: drop table Orders"),
"pending keeps running::\n{out}"
);
assert!(
out.contains("create table T with pk ✓"),
"ok shows ✓:\n{out}"
);
assert!(
out.contains("insert into T values (1) ✗"),
"err shows ✗:\n{out}"
);
assert!(
!out.contains("running: create table"),
"a completed echo drops the running: prefix:\n{out}"
@@ -2970,7 +3038,10 @@ mod tests {
#[test]
fn format_snapshot_timestamp_falls_back_on_garbage() {
assert_eq!(format_snapshot_timestamp("not a timestamp"), "not a timestamp");
assert_eq!(
format_snapshot_timestamp("not a timestamp"),
"not a timestamp"
);
}
#[test]
@@ -2999,9 +3070,9 @@ mod tests {
let theme = Theme::dark();
let out = render_to_string(&mut app, &theme, 120, 30);
assert!(
out.lines().any(|l| l.contains(
"This will undo: insert into Customers values (1, 'Oliver Sturm')"
)),
out.lines()
.any(|l| l
.contains("This will undo: insert into Customers values (1, 'Oliver Sturm')")),
"command must sit on one row on a wide terminal:\n{out}"
);
}
@@ -3017,7 +3088,10 @@ mod tests {
}));
let theme = Theme::dark();
let out = render_to_string(&mut app, &theme, 120, 30);
assert!(out.contains("Snapshot taken"), "capitalized Snapshot:\n{out}");
assert!(
out.contains("Snapshot taken"),
"capitalized Snapshot:\n{out}"
);
assert!(out.contains("[Y] Yes"), "capitalized Yes:\n{out}");
assert!(out.contains("[N] No"), "capitalized No:\n{out}");
assert!(
@@ -3113,8 +3187,14 @@ mod tests {
app.schema_cache.table_indexes.insert(
"Customers".to_string(),
vec![
IndexEntry { name: "idx_email".to_string(), unique: false },
IndexEntry { name: "uidx_login".to_string(), unique: true },
IndexEntry {
name: "idx_email".to_string(),
unique: false,
},
IndexEntry {
name: "uidx_login".to_string(),
unique: true,
},
],
);
let theme = Theme::dark();
@@ -3123,7 +3203,10 @@ mod tests {
assert!(out.contains("Customers"), "table listed:\n{out}");
assert!(out.contains("Orders"), "table listed:\n{out}");
assert!(out.contains("idx_email"), "index nested in panel:\n{out}");
assert!(out.contains("uidx_login [unique]"), "unique index marked:\n{out}");
assert!(
out.contains("uidx_login [unique]"),
"unique index marked:\n{out}"
);
}
#[test]
@@ -3143,10 +3226,19 @@ mod tests {
app.tables = vec!["Customers".to_string()];
let theme = Theme::dark();
let narrow = render_to_string(&mut app, &theme, 80, 24);
assert!(!narrow.contains("Tables"), "sidebar hidden at 80 wide:\n{narrow}");
assert!(
!narrow.contains("Tables"),
"sidebar hidden at 80 wide:\n{narrow}"
);
let wide = render_to_string(&mut app, &theme, 110, 24);
assert!(wide.contains("Tables"), "sidebar shown at 110 wide:\n{wide}");
assert!(wide.contains("Customers"), "tables listed when shown:\n{wide}");
assert!(
wide.contains("Tables"),
"sidebar shown at 110 wide:\n{wide}"
);
assert!(
wide.contains("Customers"),
"tables listed when shown:\n{wide}"
);
}
#[test]
@@ -3181,7 +3273,10 @@ mod tests {
let theme = Theme::dark();
let out = render_to_string(&mut app, &theme, 110, 24);
assert!(out.contains("Relationships"), "panel title present:\n{out}");
assert!(out.contains("Customers_Orders"), "relationship name:\n{out}");
assert!(
out.contains("Customers_Orders"),
"relationship name:\n{out}"
);
assert!(
out.lines().any(|l| l.contains("Customers.id ->")),
"parent endpoint, broken at the arrow:\n{out}"
@@ -3228,8 +3323,14 @@ mod tests {
app.nav_focus = NavFocus::SidebarTables;
let focused = render_to_string(&mut app, &theme, 80, 24);
assert!(focused.contains("Tables"), "sidebar revealed in nav mode:\n{focused}");
assert!(focused.contains("Customers"), "tables in the overlay:\n{focused}");
assert!(
focused.contains("Tables"),
"sidebar revealed in nav mode:\n{focused}"
);
assert!(
focused.contains("Customers"),
"tables in the overlay:\n{focused}"
);
assert!(
focused.contains("Relationships"),
"relationships panel in the overlay:\n{focused}"
@@ -3365,7 +3466,9 @@ mod tests {
}
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).expect("create terminal");
terminal.draw(|f| render(app, theme, f)).expect("draw frame");
terminal
.draw(|f| render(app, theme, f))
.expect("draw frame");
terminal.backend().buffer().clone()
}
+15 -3
View File
@@ -737,7 +737,11 @@ mod tests {
let payload_dirs = fs::read_dir(&store.root)
.unwrap()
.filter_map(std::result::Result::ok)
.filter(|e| e.file_name().to_str().is_some_and(|n| n.parse::<u64>().is_ok()))
.filter(|e| {
e.file_name()
.to_str()
.is_some_and(|n| n.parse::<u64>().is_ok())
})
.count();
assert_eq!(payload_dirs, 2, "ring capped at 2 payloads");
}
@@ -766,7 +770,11 @@ mod tests {
let payload_dirs = fs::read_dir(&store.root)
.unwrap()
.filter_map(std::result::Result::ok)
.filter(|e| e.file_name().to_str().is_some_and(|n| n.parse::<u64>().is_ok()))
.filter(|e| {
e.file_name()
.to_str()
.is_some_and(|n| n.parse::<u64>().is_ok())
})
.count();
assert_eq!(payload_dirs, 1, "only the surviving undo payload remains");
}
@@ -820,6 +828,10 @@ mod tests {
fs::create_dir_all(store.payload_dir(41)).unwrap();
stage_finalize(&store, &fx.conn, "cmd");
let meta = store.peek_undo().unwrap().unwrap();
assert!(meta.id >= 42, "id allocated above the orphan, got {}", meta.id);
assert!(
meta.id >= 42,
"id allocated above the orphan, got {}",
meta.id
);
}
}