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:
+53
-6
@@ -476,10 +476,23 @@ 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"));
|
||||
|
||||
const ADD_RELATIONSHIP_NODES: &[Node] = &[
|
||||
// The `1:n` cardinality marker, tagged as the
|
||||
// `cardinality_one_to_many` clause-concept region (issue #37). A
|
||||
// distinct sub-`Seq` so the concept span covers exactly `1:n`, not
|
||||
// the whole relationship form (which the per-form `hint.cmd.*` block
|
||||
// already serves).
|
||||
const CARDINALITY_1N_NODES: &[Node] = &[
|
||||
Node::Literal("1"),
|
||||
Node::Punct(':'),
|
||||
Node::Word(Word::keyword("n")),
|
||||
];
|
||||
const CARDINALITY_1N: Node = Node::Concept {
|
||||
topic: "cardinality_one_to_many",
|
||||
inner: &Node::Seq(CARDINALITY_1N_NODES),
|
||||
};
|
||||
|
||||
const ADD_RELATIONSHIP_NODES: &[Node] = &[
|
||||
CARDINALITY_1N,
|
||||
Node::Word(Word::keyword("relationship")),
|
||||
AR_AS_NAME_OPT,
|
||||
Node::Word(Word::keyword("from")),
|
||||
@@ -1048,7 +1061,14 @@ const NOT_NULL_NODES: &[Node] = &[
|
||||
];
|
||||
const NOT_NULL_CONSTRAINT: Node = Node::Seq(NOT_NULL_NODES);
|
||||
|
||||
const UNIQUE_CONSTRAINT: Node = Node::Word(Word::keyword("unique"));
|
||||
// Tagged as the `unique` clause-concept region (issue #37). Shared
|
||||
// across `create table … with pk`, `add constraint`, and (as a bare
|
||||
// keyword) `drop constraint`, so the concept surfaces anywhere a
|
||||
// unique constraint is being declared or removed.
|
||||
const UNIQUE_CONSTRAINT: Node = Node::Concept {
|
||||
topic: "unique",
|
||||
inner: &Node::Word(Word::keyword("unique")),
|
||||
};
|
||||
|
||||
const DEFAULT_CONSTRAINT_NODES: &[Node] = &[
|
||||
Node::Word(Word::keyword("default")),
|
||||
@@ -1066,7 +1086,13 @@ const CHECK_CONSTRAINT_NODES: &[Node] = &[
|
||||
Node::Subgrammar(&super::expr::OR_EXPR),
|
||||
Node::Punct(')'),
|
||||
];
|
||||
const CHECK_CONSTRAINT: Node = Node::Seq(CHECK_CONSTRAINT_NODES);
|
||||
// Tagged as the `check` clause-concept region (issue #37). The span
|
||||
// covers the whole `check ( <expr> )`, so a cursor inside the
|
||||
// predicate still resolves the concept.
|
||||
const CHECK_CONSTRAINT: Node = Node::Concept {
|
||||
topic: "check",
|
||||
inner: &Node::Seq(CHECK_CONSTRAINT_NODES),
|
||||
};
|
||||
|
||||
const COLUMN_CONSTRAINT_CHOICES: &[Node] = &[
|
||||
NOT_NULL_CONSTRAINT,
|
||||
@@ -1158,11 +1184,20 @@ const SPEC_LIST: Node = Node::Repeated {
|
||||
};
|
||||
const SPEC_LIST_OPT: Node = Node::Optional(&SPEC_LIST);
|
||||
|
||||
const WITH_PK_NODES: &[Node] = &[
|
||||
// The `with pk` keywords, tagged as the `primary_key` clause-concept
|
||||
// region (issue #37). Wrapping just the keywords (not the column
|
||||
// `SPEC_LIST` that follows) keeps the concept crisp: a cursor on
|
||||
// `with pk` teaches primary keys, while a cursor on a column's own
|
||||
// `unique` / `check` constraint resolves to that narrower concept.
|
||||
const WITH_PK_MARKER_NODES: &[Node] = &[
|
||||
Node::Word(Word::keyword("with")),
|
||||
Node::Word(Word::keyword("pk")),
|
||||
SPEC_LIST_OPT,
|
||||
];
|
||||
const WITH_PK_MARKER: Node = Node::Concept {
|
||||
topic: "primary_key",
|
||||
inner: &Node::Seq(WITH_PK_MARKER_NODES),
|
||||
};
|
||||
const WITH_PK_NODES: &[Node] = &[WITH_PK_MARKER, SPEC_LIST_OPT];
|
||||
const WITH_PK: Node = Node::Seq(WITH_PK_NODES);
|
||||
const WITH_PK_OPT: Node = Node::Optional(&WITH_PK);
|
||||
|
||||
@@ -1424,10 +1459,22 @@ const M2N_T2: Node = Node::Ident {
|
||||
const M2N_AS_NAME_NODES: &[Node] = &[Node::Word(Word::keyword("as")), TABLE_NAME_NEW];
|
||||
const M2N_AS_NAME_OPT: Node = Node::Optional(&Node::Seq(M2N_AS_NAME_NODES));
|
||||
|
||||
const CREATE_M2N_NODES: &[Node] = &[
|
||||
// The `m:n` cardinality marker, tagged as the
|
||||
// `cardinality_many_to_many` clause-concept region (issue #37).
|
||||
// Mirrors `CARDINALITY_1N` — a distinct sub-`Seq` so the span covers
|
||||
// exactly `m:n`.
|
||||
const CARDINALITY_MN_NODES: &[Node] = &[
|
||||
Node::Literal("m"),
|
||||
Node::Punct(':'),
|
||||
Node::Word(Word::keyword("n")),
|
||||
];
|
||||
const CARDINALITY_MN: Node = Node::Concept {
|
||||
topic: "cardinality_many_to_many",
|
||||
inner: &Node::Seq(CARDINALITY_MN_NODES),
|
||||
};
|
||||
|
||||
const CREATE_M2N_NODES: &[Node] = &[
|
||||
CARDINALITY_MN,
|
||||
Node::Word(Word::keyword("relationship")),
|
||||
Node::Word(Word::keyword("from")),
|
||||
M2N_T1,
|
||||
|
||||
@@ -476,6 +476,26 @@ pub enum Node {
|
||||
mode: HintMode,
|
||||
inner: &'static Self,
|
||||
},
|
||||
/// Annotates `inner` as a recognized *clause-concept* region
|
||||
/// (issue #37 / ADR clause-concept-hints, D1). Transparent to
|
||||
/// matching, highlighting and the expected-set — it walks
|
||||
/// `inner` and returns its result verbatim — but as a side
|
||||
/// effect the walker records the byte span the clause covered
|
||||
/// into `WalkContext::concept_spans`, tagged with `topic` (a
|
||||
/// `hint.concept.<topic>` catalogue key). The F1 hint surface
|
||||
/// reads those spans to layer a clause-level teaching block on
|
||||
/// top of the per-form `hint.cmd.*` block when the cursor sits
|
||||
/// inside the clause.
|
||||
///
|
||||
/// Sibling of `Hinted`, but where `Hinted` records a single
|
||||
/// *pending* slot mode (cleared on the next match, so it marks
|
||||
/// the slot the cursor is *about to fill*), `Concept` appends a
|
||||
/// durable span that survives the clause being fully matched —
|
||||
/// the "cursor anywhere inside the clause" semantics #37 wants.
|
||||
Concept {
|
||||
topic: &'static str,
|
||||
inner: &'static Self,
|
||||
},
|
||||
}
|
||||
|
||||
/// Which mode group a registered command belongs to (ADR-0030
|
||||
@@ -1087,6 +1107,173 @@ mod hint_key_tests {
|
||||
"expected at least 49 hint.cmd.* examples, checked {checked}",
|
||||
);
|
||||
}
|
||||
|
||||
// ── Clause-concept comprehensiveness (issue #37 / clause-concept-
|
||||
// hints D7) ────────────────────────────────────────────────────
|
||||
|
||||
/// A concept topic's mode reachability — drives both the example
|
||||
/// shape (both-mode topics carry `example.simple`+`.advanced`;
|
||||
/// single-mode topics a plain `example`) and the mode each example
|
||||
/// must parse in.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
enum ConceptModes {
|
||||
Both,
|
||||
SimpleOnly,
|
||||
AdvancedOnly,
|
||||
}
|
||||
|
||||
/// Every clause-concept topic + its mode coverage (D4). Source of
|
||||
/// truth the gates below cross-check against the grammar wrappers
|
||||
/// and the catalogue, so a wrapper or block can't drift out of sync.
|
||||
const CONCEPT_TOPICS: &[(&str, ConceptModes)] = &[
|
||||
("referential_actions", ConceptModes::Both),
|
||||
("cardinality_one_to_many", ConceptModes::SimpleOnly),
|
||||
("cardinality_many_to_many", ConceptModes::SimpleOnly),
|
||||
("primary_key", ConceptModes::Both),
|
||||
("unique", ConceptModes::Both),
|
||||
("check", ConceptModes::Both),
|
||||
("foreign_key", ConceptModes::AdvancedOnly),
|
||||
];
|
||||
|
||||
/// Recursively collect every `Node::Concept` topic reachable from a
|
||||
/// shape, following the static-child edges **and** `Subgrammar`/
|
||||
/// `ScopedSubgrammar` references (the SQL `create table` body — and
|
||||
/// thus the advanced `foreign_key` / constraint wrappers — hangs off
|
||||
/// the command shape through a `Subgrammar`). Cycles in the
|
||||
/// expression grammar are broken by a visited-by-address set; every
|
||||
/// `Node` lives in static memory, so its address is a stable
|
||||
/// identity. `DynamicSubgrammar` / `Lookahead` build nodes at walk
|
||||
/// time and can't be traversed statically — no concept wrapper hides
|
||||
/// behind one (asserted indirectly: every declared topic is reached).
|
||||
fn collect_concept_topics(
|
||||
node: &super::Node,
|
||||
visited: &mut std::collections::BTreeSet<usize>,
|
||||
out: &mut std::collections::BTreeSet<&'static str>,
|
||||
) {
|
||||
use super::Node;
|
||||
let addr = std::ptr::from_ref(node) as usize;
|
||||
if !visited.insert(addr) {
|
||||
return;
|
||||
}
|
||||
match node {
|
||||
Node::Concept { topic, inner } => {
|
||||
out.insert(topic);
|
||||
collect_concept_topics(inner, visited, out);
|
||||
}
|
||||
Node::Seq(children) | Node::Choice(children) => {
|
||||
for c in *children {
|
||||
collect_concept_topics(c, visited, out);
|
||||
}
|
||||
}
|
||||
Node::Optional(inner)
|
||||
| Node::Repeated { inner, .. }
|
||||
| Node::Hinted { inner, .. }
|
||||
| Node::TypedValueSlot { inner, .. }
|
||||
| Node::Subgrammar(inner)
|
||||
| Node::ScopedSubgrammar(inner) => {
|
||||
collect_concept_topics(inner, visited, out);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Gate: the set of `Node::Concept` topics wired into the grammar
|
||||
/// exactly matches `CONCEPT_TOPICS` — neither a wrapper without a
|
||||
/// declared topic, nor a declared topic without a wrapper.
|
||||
#[test]
|
||||
fn concept_wrappers_match_declared_topics() {
|
||||
let mut found = std::collections::BTreeSet::new();
|
||||
let mut visited = std::collections::BTreeSet::new();
|
||||
for (node, _category) in super::REGISTRY {
|
||||
collect_concept_topics(&node.shape, &mut visited, &mut found);
|
||||
}
|
||||
let declared: std::collections::BTreeSet<&str> =
|
||||
CONCEPT_TOPICS.iter().map(|(t, _)| *t).collect();
|
||||
for t in &found {
|
||||
assert!(
|
||||
declared.contains(t),
|
||||
"grammar has Node::Concept topic {t:?} missing from CONCEPT_TOPICS",
|
||||
);
|
||||
}
|
||||
for (t, _) in CONCEPT_TOPICS {
|
||||
assert!(
|
||||
found.contains(t),
|
||||
"CONCEPT_TOPICS lists {t:?} but no Node::Concept wrapper reaches it",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Gate: every topic resolves to a `hint.concept.<topic>` block with
|
||||
/// `what` + `concept`, and the example shape matches its mode
|
||||
/// coverage (mode-keyed for Both, plain for single-mode). `keys.rs`
|
||||
/// checks referenced keys resolve; this checks every topic *has* a
|
||||
/// block of the right shape.
|
||||
#[test]
|
||||
fn every_concept_topic_has_a_block() {
|
||||
let cat = crate::friendly::catalog();
|
||||
for (topic, modes) in CONCEPT_TOPICS {
|
||||
for part in ["what", "concept"] {
|
||||
assert!(
|
||||
cat.get(&format!("hint.concept.{topic}.{part}")).is_some(),
|
||||
"missing hint.concept.{topic}.{part}",
|
||||
);
|
||||
}
|
||||
let simple = cat.get(&format!("hint.concept.{topic}.example.simple"));
|
||||
let advanced = cat.get(&format!("hint.concept.{topic}.example.advanced"));
|
||||
let plain = cat.get(&format!("hint.concept.{topic}.example"));
|
||||
match modes {
|
||||
ConceptModes::Both => {
|
||||
assert!(
|
||||
simple.is_some() && advanced.is_some() && plain.is_none(),
|
||||
"{topic} is Both: needs example.simple + example.advanced, no plain example"
|
||||
);
|
||||
}
|
||||
ConceptModes::SimpleOnly | ConceptModes::AdvancedOnly => {
|
||||
assert!(
|
||||
plain.is_some() && simple.is_none() && advanced.is_none(),
|
||||
"{topic} is single-mode: needs a plain example, no mode-keyed variants"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Semantic guard (mirrors `every_cmd_hint_example_parses_in_its_mode`):
|
||||
/// every clause-concept example parses in the mode it is taught for,
|
||||
/// so an example can't drift out of the real grammar.
|
||||
#[test]
|
||||
fn every_concept_example_parses_in_its_mode() {
|
||||
use crate::dsl::parser::parse_command_in_mode;
|
||||
use crate::mode::Mode;
|
||||
let cat = crate::friendly::catalog();
|
||||
let parses = |key: &str, mode: Mode| {
|
||||
let example = cat.get(key).unwrap_or_else(|| panic!("missing {key}"));
|
||||
assert!(
|
||||
parse_command_in_mode(example, mode).is_ok(),
|
||||
"{key} does not parse in {mode:?} mode: {example:?}",
|
||||
);
|
||||
};
|
||||
for (topic, modes) in CONCEPT_TOPICS {
|
||||
match modes {
|
||||
ConceptModes::Both => {
|
||||
parses(
|
||||
&format!("hint.concept.{topic}.example.simple"),
|
||||
Mode::Simple,
|
||||
);
|
||||
parses(
|
||||
&format!("hint.concept.{topic}.example.advanced"),
|
||||
Mode::Advanced,
|
||||
);
|
||||
}
|
||||
ConceptModes::SimpleOnly => {
|
||||
parses(&format!("hint.concept.{topic}.example"), Mode::Simple);
|
||||
}
|
||||
ConceptModes::AdvancedOnly => {
|
||||
parses(&format!("hint.concept.{topic}.example"), Mode::Advanced);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -133,12 +133,25 @@ pub const ON_CLAUSE: Node = Node::Seq(ON_CLAUSE_NODES);
|
||||
/// Repeated `on <target> <action>` clauses (0..2 occurrences).
|
||||
/// Validation of "specified twice" + max=2 lives in the
|
||||
/// command's AST builder.
|
||||
pub const REFERENTIAL_CLAUSES: Node = Node::Repeated {
|
||||
const REFERENTIAL_CLAUSES_INNER: Node = Node::Repeated {
|
||||
inner: &ON_CLAUSE,
|
||||
separator: None,
|
||||
min: 0,
|
||||
};
|
||||
|
||||
/// As [`REFERENTIAL_CLAUSES_INNER`], tagged as the
|
||||
/// `referential_actions` clause-concept region (issue #37).
|
||||
///
|
||||
/// Shared by the simple-mode `add 1:n relationship … on delete/update`
|
||||
/// and the advanced-mode `references … on delete/update` clause, so the
|
||||
/// one wrapper surfaces the concept in both modes (the mode-keyed
|
||||
/// example is chosen by the live mode at F1 time, not by which grammar
|
||||
/// matched).
|
||||
pub const REFERENTIAL_CLAUSES: Node = Node::Concept {
|
||||
topic: "referential_actions",
|
||||
inner: &REFERENTIAL_CLAUSES_INNER,
|
||||
};
|
||||
|
||||
// =================================================================
|
||||
// Typed value slots (ADR-0024 §Phase D, §typed-value-slots)
|
||||
// =================================================================
|
||||
|
||||
@@ -204,16 +204,38 @@ static REFERENCES_NODES: &[Node] = &[
|
||||
shared::REFERENTIAL_CLAUSES,
|
||||
];
|
||||
const REFERENCES_CLAUSE: Node = Node::Seq(REFERENCES_NODES);
|
||||
// The `foreign_key` clause-concept region (issue #37) — wraps the
|
||||
// whole `references …` clause. It contains `shared::REFERENTIAL_CLAUSES`
|
||||
// (the `referential_actions` concept), so a cursor on `references
|
||||
// <parent>` resolves to `foreign_key` while a cursor on the inner `on
|
||||
// delete …` resolves to the narrower `referential_actions` (D2
|
||||
// innermost-wins).
|
||||
const REFERENCES_CONCEPT: Node = Node::Concept {
|
||||
topic: "foreign_key",
|
||||
inner: &REFERENCES_CLAUSE,
|
||||
};
|
||||
// `NOT NULL` | `UNIQUE` | `PRIMARY KEY` | `DEFAULT <expr>` |
|
||||
// `CHECK (<expr>)`. Each branch starts on a distinct keyword, so the
|
||||
// `Choice` never ambiguously commits.
|
||||
// `Choice` never ambiguously commits. The relational-concept branches
|
||||
// (`unique`, `primary key`, `check`, `references`) carry a
|
||||
// `Node::Concept` tag (issue #37); `not null` / `default` deliberately
|
||||
// do not (D4 omission).
|
||||
static COL_CONSTRAINT_CHOICES: &[Node] = &[
|
||||
Node::Seq(NOT_NULL_NODES),
|
||||
Node::Word(Word::keyword("unique")),
|
||||
Node::Seq(PRIMARY_KEY_NODES),
|
||||
Node::Concept {
|
||||
topic: "unique",
|
||||
inner: &Node::Word(Word::keyword("unique")),
|
||||
},
|
||||
Node::Concept {
|
||||
topic: "primary_key",
|
||||
inner: &Node::Seq(PRIMARY_KEY_NODES),
|
||||
},
|
||||
Node::Seq(DEFAULT_NODES),
|
||||
Node::Seq(CHECK_NODES),
|
||||
REFERENCES_CLAUSE,
|
||||
Node::Concept {
|
||||
topic: "check",
|
||||
inner: &Node::Seq(CHECK_NODES),
|
||||
},
|
||||
REFERENCES_CONCEPT,
|
||||
];
|
||||
const COL_CONSTRAINT: Node = Node::Choice(COL_CONSTRAINT_CHOICES);
|
||||
/// Zero-or-more column constraints after the type (`min: 0`).
|
||||
|
||||
@@ -134,6 +134,18 @@ pub struct WalkContext<'a> {
|
||||
/// resolver reads this directly instead of inferring the
|
||||
/// slot kind from the shape of the expected set.
|
||||
pub pending_hint_mode: Option<crate::dsl::grammar::HintMode>,
|
||||
/// Byte spans of the clause-concept regions the walk passed
|
||||
/// through (issue #37 / ADR clause-concept-hints, D1).
|
||||
///
|
||||
/// A `Node::Concept { topic, inner }` wrapper pushes one entry
|
||||
/// per *committed* inner walk (Matched / Incomplete / Failed —
|
||||
/// never on NoMatch). Unlike `pending_hint_mode`, this is
|
||||
/// **append-only and never cleared on match**, so a fully-typed
|
||||
/// clause keeps its span — that is what lets the F1
|
||||
/// clause-concept resolver report a concept for a cursor parked
|
||||
/// *inside* already-typed clause text, not only at the slot
|
||||
/// boundary.
|
||||
pub concept_spans: Vec<ConceptSpan>,
|
||||
/// An `IntroProse` hint captured from an *optional* slot that
|
||||
/// the walk skipped (issue #26). Unlike `pending_hint_mode`
|
||||
/// (cleared on the very next match — including the empty match
|
||||
@@ -227,6 +239,19 @@ pub struct PendingCteHarvest {
|
||||
pub cte_name_span: (usize, usize),
|
||||
}
|
||||
|
||||
/// A clause-concept region recorded during a walk (issue #37).
|
||||
///
|
||||
/// `topic` is the `hint.concept.<topic>` catalogue key; `[start,
|
||||
/// end]` is the inclusive byte range the clause covered as typed so
|
||||
/// far (`end` is the matched end on a full match, or the stop
|
||||
/// position on an incomplete / failed inner).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ConceptSpan {
|
||||
pub topic: &'static str,
|
||||
pub start: usize,
|
||||
pub end: usize,
|
||||
}
|
||||
|
||||
impl<'a> WalkContext<'a> {
|
||||
/// Schemaless walk context — the legacy default used by
|
||||
/// pre-Phase-D callers and tests that don't care about
|
||||
@@ -243,6 +268,7 @@ impl<'a> WalkContext<'a> {
|
||||
pending_value_type: None,
|
||||
pending_value_column: None,
|
||||
pending_hint_mode: None,
|
||||
concept_spans: Vec::new(),
|
||||
surviving_intro_hint: None,
|
||||
user_listed_columns: None,
|
||||
subgrammar_depth: 0,
|
||||
@@ -266,6 +292,7 @@ impl<'a> WalkContext<'a> {
|
||||
pending_value_type: None,
|
||||
pending_value_column: None,
|
||||
pending_hint_mode: None,
|
||||
concept_spans: Vec::new(),
|
||||
surviving_intro_hint: None,
|
||||
user_listed_columns: None,
|
||||
subgrammar_depth: 0,
|
||||
|
||||
@@ -290,6 +290,33 @@ fn walk_node_inner(
|
||||
ctx.pending_hint_mode = Some(*mode);
|
||||
walk_node(source, pos, inner, ctx, path, per_byte)
|
||||
}
|
||||
Node::Concept { topic, inner } => {
|
||||
// Issue #37 / ADR clause-concept-hints D1. Walk the
|
||||
// inner clause, then record its covered byte span in
|
||||
// `ctx.concept_spans` iff the inner *committed* — i.e.
|
||||
// matched, ran out mid-clause (Incomplete), or hit a
|
||||
// hard failure after engaging (Failed). On `NoMatch`
|
||||
// the node never engaged (e.g. a `Choice` tried this
|
||||
// branch and it didn't apply), so record nothing — no
|
||||
// stale span. `start` is the post-whitespace position
|
||||
// the walk wrapper already resolved.
|
||||
let result = walk_node(source, pos, inner, ctx, path, per_byte);
|
||||
let end = match &result {
|
||||
NodeWalkResult::Matched { end, .. } => Some(*end),
|
||||
NodeWalkResult::Incomplete { position, .. }
|
||||
| NodeWalkResult::Failed { position, .. } => Some(*position),
|
||||
NodeWalkResult::NoMatch { .. } => None,
|
||||
};
|
||||
if let Some(end) = end {
|
||||
ctx.concept_spans
|
||||
.push(crate::dsl::walker::context::ConceptSpan {
|
||||
topic,
|
||||
start: pos,
|
||||
end,
|
||||
});
|
||||
}
|
||||
result
|
||||
}
|
||||
Node::Flag(name) => walk_flag(source, pos, name, path, per_byte),
|
||||
Node::Repeated {
|
||||
inner,
|
||||
|
||||
+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