feat(hint): clause-concept hints layered on F1 (issue #37)
Add a hint.concept.* tier-3 layer surfaced when the cursor sits inside a recognized clause (referential actions, 1:n/m:n cardinality, primary key, unique, check, foreign key), layered beneath the per-form block. New Node::Concept grammar wrapper records clause byte-spans; concept_topic_at_cursor resolves the innermost containing span. Examples are mode-keyed so they stay syntax-correct in both simple and advanced mode. Draft ADR (number at merge).
This commit is contained in:
+186
-1
@@ -25,7 +25,7 @@ use crate::dsl::walker::driver::{FailureKind, NodeWalkResult, walk_node};
|
||||
use crate::dsl::walker::lex_helpers::{consume_ident, skip_whitespace};
|
||||
use crate::dsl::walker::outcome::{Expectation, MatchedPath, WalkBound, WalkOutcome, WalkResult};
|
||||
|
||||
pub use context::ColumnInfo;
|
||||
pub use context::{ColumnInfo, ConceptSpan};
|
||||
pub use highlight::{highlight_runs, highlight_runs_in_mode};
|
||||
pub use outcome::{Diagnostic, Severity};
|
||||
|
||||
@@ -167,6 +167,46 @@ pub fn hint_resolution_at_input_in_mode(
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the clause-concept topic the cursor sits inside, if any
|
||||
/// (issue #37 / ADR clause-concept-hints, D2).
|
||||
///
|
||||
/// Walks the **full** `source` (not a cursor-trimmed prefix — the
|
||||
/// dormant `WalkBound::Position` path is deliberately not used, so
|
||||
/// the whole parse and every clause span is available), collects the
|
||||
/// `Node::Concept` spans recorded in `ctx.concept_spans`, and returns
|
||||
/// the `topic` of the **innermost** span containing `cursor`. The F1
|
||||
/// hint surface layers `hint.concept.<topic>` on top of the per-form
|
||||
/// block when this is `Some`.
|
||||
///
|
||||
/// "Innermost" = the containing span with the latest `start`, tie-
|
||||
/// broken by the earliest `end` (narrowest): when a `references`
|
||||
/// clause (`foreign_key`) wraps an `on delete` clause
|
||||
/// (`referential_actions`), a cursor on `on delete` resolves to the
|
||||
/// more specific inner concept. Containment is inclusive at both ends
|
||||
/// so the boundary case (cursor exactly at the clause start) also
|
||||
/// resolves.
|
||||
#[must_use]
|
||||
pub fn concept_topic_at_cursor(
|
||||
source: &str,
|
||||
cursor: usize,
|
||||
schema: Option<&crate::completion::SchemaCache>,
|
||||
mode: crate::mode::Mode,
|
||||
) -> Option<&'static str> {
|
||||
if source.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut ctx = schema.map_or_else(context::WalkContext::new, |s| {
|
||||
context::WalkContext::with_schema(s)
|
||||
});
|
||||
ctx.mode = mode;
|
||||
let _ = walk(source, outcome::WalkBound::EndOfInput, &mut ctx);
|
||||
ctx.concept_spans
|
||||
.iter()
|
||||
.filter(|s| s.start <= cursor && cursor <= s.end)
|
||||
.max_by(|a, b| a.start.cmp(&b.start).then(b.end.cmp(&a.end)))
|
||||
.map(|s| s.topic)
|
||||
}
|
||||
|
||||
/// Auto-generated columns a Form B insert skips from its value
|
||||
/// list — but only when the cursor sits at the *first* value
|
||||
/// slot, so the pedagogical note fires once per command rather
|
||||
@@ -7028,3 +7068,148 @@ mod order_by_expected_set_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod concept_hint_tests {
|
||||
//! Issue #37 / ADR clause-concept-hints — `concept_topic_at_cursor`.
|
||||
//!
|
||||
//! The cursor is positioned by `find`-ing a substring so the
|
||||
//! tests don't hard-code byte offsets. "inside" means the cursor
|
||||
//! sits within already-typed clause text (the cursor-inside
|
||||
//! semantics, not merely the slot boundary).
|
||||
use super::concept_topic_at_cursor;
|
||||
use crate::mode::Mode;
|
||||
|
||||
/// Cursor at the START of `needle` within `src`.
|
||||
fn at(src: &str, needle: &str) -> usize {
|
||||
src.find(needle).expect("needle present")
|
||||
}
|
||||
/// Cursor in the MIDDLE of `needle` within `src` (inside typed text).
|
||||
fn inside(src: &str, needle: &str) -> usize {
|
||||
at(src, needle) + needle.len() / 2
|
||||
}
|
||||
|
||||
fn topic(src: &str, cursor: usize, mode: Mode) -> Option<&'static str> {
|
||||
concept_topic_at_cursor(src, cursor, None, mode)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn referential_actions_at_on_delete_boundary() {
|
||||
let s = "add 1:n relationship from Customers.id to Orders.cid on delete cascade";
|
||||
assert_eq!(
|
||||
topic(s, at(s, "on delete"), Mode::Simple),
|
||||
Some("referential_actions"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn referential_actions_inside_already_typed_action() {
|
||||
// Cursor parked inside the finished `cascade` — the
|
||||
// cursor-inside case the slot-boundary mechanism can't serve.
|
||||
let s = "add 1:n relationship from Customers.id to Orders.cid on delete cascade";
|
||||
assert_eq!(
|
||||
topic(s, inside(s, "cascade"), Mode::Simple),
|
||||
Some("referential_actions"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cardinality_one_to_many_on_marker() {
|
||||
let s = "add 1:n relationship from Customers.id to Orders.cid";
|
||||
assert_eq!(
|
||||
topic(s, inside(s, "1:n"), Mode::Simple),
|
||||
Some("cardinality_one_to_many"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cardinality_many_to_many_on_marker() {
|
||||
let s = "create m:n relationship from Students to Courses";
|
||||
assert_eq!(
|
||||
topic(s, inside(s, "m:n"), Mode::Simple),
|
||||
Some("cardinality_many_to_many"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn primary_key_in_with_pk_simple() {
|
||||
let s = "create table Orders with pk Code(text)";
|
||||
assert_eq!(
|
||||
topic(s, inside(s, "with pk"), Mode::Simple),
|
||||
Some("primary_key"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn primary_key_in_create_table_constraint_advanced() {
|
||||
let s = "create table Orders (id int primary key)";
|
||||
assert_eq!(
|
||||
topic(s, inside(s, "primary key"), Mode::Advanced),
|
||||
Some("primary_key"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unique_constraint_simple() {
|
||||
let s = "create table Ages with pk age(int) unique";
|
||||
assert_eq!(topic(s, inside(s, "unique"), Mode::Simple), Some("unique"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unique_constraint_advanced() {
|
||||
let s = "create table Orders (Code text unique)";
|
||||
assert_eq!(
|
||||
topic(s, inside(s, "unique"), Mode::Advanced),
|
||||
Some("unique")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_constraint_simple() {
|
||||
let s = "create table Ages with pk age(int) check (age > 0)";
|
||||
assert_eq!(topic(s, inside(s, "check"), Mode::Simple), Some("check"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_constraint_advanced() {
|
||||
let s = "create table Orders (Qty int check (Qty > 0))";
|
||||
assert_eq!(topic(s, inside(s, "check"), Mode::Advanced), Some("check"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foreign_key_references_advanced() {
|
||||
let s = "create table Orders (cid int references Customers(id))";
|
||||
assert_eq!(
|
||||
topic(s, inside(s, "references"), Mode::Advanced),
|
||||
Some("foreign_key"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_on_delete_inside_references_resolves_innermost() {
|
||||
// `references … on delete cascade` nests referential_actions
|
||||
// inside foreign_key; cursor on `on delete` → inner concept.
|
||||
let s = "create table Orders (cid int references Customers(id) on delete cascade)";
|
||||
assert_eq!(
|
||||
topic(s, at(s, "on delete"), Mode::Advanced),
|
||||
Some("referential_actions"),
|
||||
);
|
||||
// …but a cursor on `references` → the outer foreign_key.
|
||||
assert_eq!(
|
||||
topic(s, inside(s, "references"), Mode::Advanced),
|
||||
Some("foreign_key"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_outside_any_clause_is_none() {
|
||||
let s = "add 1:n relationship from Customers.id to Orders.cid on delete cascade";
|
||||
// On the entry word `add` — no concept clause there.
|
||||
assert_eq!(topic(s, at(s, "add"), Mode::Simple), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_input_is_none() {
|
||||
assert_eq!(topic("", 0, Mode::Simple), None);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user