use nom_language::error::{VerboseError, VerboseErrorKind};
use std::fmt::Display;
use thiserror::Error;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KipErrorCode {
InvalidSyntax,
InvalidIdentifier,
TypeMismatch,
ConstraintViolation,
InvalidValueType,
ReferenceError,
NotFound,
DuplicateExists,
ImmutableTarget,
VersionConflict,
ExecutionTimeout,
ResourceExhausted,
InternalError,
}
impl KipErrorCode {
pub fn code(&self) -> &'static str {
match self {
Self::InvalidSyntax => "KIP_1001",
Self::InvalidIdentifier => "KIP_1002",
Self::TypeMismatch => "KIP_2001",
Self::ConstraintViolation => "KIP_2002",
Self::InvalidValueType => "KIP_2003",
Self::ReferenceError => "KIP_3001",
Self::NotFound => "KIP_3002",
Self::DuplicateExists => "KIP_3003",
Self::ImmutableTarget => "KIP_3004",
Self::VersionConflict => "KIP_3005",
Self::ExecutionTimeout => "KIP_4001",
Self::ResourceExhausted => "KIP_4002",
Self::InternalError => "KIP_4003",
}
}
pub fn name(&self) -> &'static str {
match self {
Self::InvalidSyntax => "InvalidSyntax",
Self::InvalidIdentifier => "InvalidIdentifier",
Self::TypeMismatch => "TypeMismatch",
Self::ConstraintViolation => "ConstraintViolation",
Self::InvalidValueType => "InvalidValueType",
Self::ReferenceError => "ReferenceError",
Self::NotFound => "NotFound",
Self::DuplicateExists => "DuplicateExists",
Self::ImmutableTarget => "ImmutableTarget",
Self::VersionConflict => "VersionConflict",
Self::ExecutionTimeout => "ExecutionTimeout",
Self::ResourceExhausted => "ResourceExhausted",
Self::InternalError => "InternalError",
}
}
pub fn hint(&self) -> &'static str {
match self {
Self::InvalidSyntax => {
"Check parenthesis matching, keyword spelling, and statement structure. Ensure JSON data format is valid."
}
Self::InvalidIdentifier => "Identifiers must match regex `[a-zA-Z_][a-zA-Z0-9_]*`.",
Self::TypeMismatch => {
"Execute `DESCRIBE` to confirm type names. Remember types are case-sensitive (`Drug` vs `drug`)."
}
Self::ConstraintViolation => "Supply the missing required attributes.",
Self::InvalidValueType => "Correct the JSON value type.",
Self::ReferenceError => {
"Ensure the variable is defined and bound in the WHERE clause (for KQL) or the CONCEPT block is placed before referencing clauses (for KML)."
}
Self::NotFound => {
"Target may have been deleted or never created. Try `SEARCH` or `FIND` to confirm existence first."
}
Self::DuplicateExists => {
"If intent is update, check if `UPSERT` should be used instead of creation logic."
}
Self::ImmutableTarget => {
"**Operation Prohibited.** Do not attempt to modify system meta-definitions or core identity nodes."
}
Self::VersionConflict => {
"The element changed since you read it. Re-read it (obtaining the fresh `_version`), re-apply your merge in memory, and retry with the new `EXPECT VERSION`."
}
Self::ExecutionTimeout => {
"Optimize query. Reduce `UNION` usage, lower `LIMIT`, or reduce regex/hops."
}
Self::ResourceExhausted => "Must use `LIMIT` and `CURSOR` for pagination.",
Self::InternalError => "Contact system administrator or retry later.",
}
}
}
impl Display for KipErrorCode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.code())
}
}
#[derive(Error, Debug, Clone)]
#[error("{code}: {message}")]
pub struct KipError {
pub code: KipErrorCode,
pub message: String,
}
impl KipError {
pub fn new(code: KipErrorCode, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
}
}
pub fn code_str(&self) -> &'static str {
self.code.code()
}
pub fn name(&self) -> &'static str {
self.code.name()
}
pub fn hint(&self) -> &'static str {
self.code.hint()
}
pub fn invalid_syntax(err: impl Display) -> Self {
Self::new(KipErrorCode::InvalidSyntax, format!("{err}"))
}
pub fn invalid_identifier(err: impl Display) -> Self {
Self::new(KipErrorCode::InvalidIdentifier, format!("{err}"))
}
pub fn type_mismatch(err: impl Display) -> Self {
Self::new(KipErrorCode::TypeMismatch, format!("{err}"))
}
pub fn constraint_violation(err: impl Display) -> Self {
Self::new(KipErrorCode::ConstraintViolation, format!("{err}"))
}
pub fn invalid_value_type(err: impl Display) -> Self {
Self::new(KipErrorCode::InvalidValueType, format!("{err}"))
}
pub fn reference_error(err: impl Display) -> Self {
Self::new(KipErrorCode::ReferenceError, format!("{err}"))
}
pub fn not_found(err: impl Display) -> Self {
Self::new(KipErrorCode::NotFound, format!("{err}"))
}
pub fn duplicate_exists(err: impl Display) -> Self {
Self::new(KipErrorCode::DuplicateExists, format!("{err}"))
}
pub fn immutable_target(err: impl Display) -> Self {
Self::new(KipErrorCode::ImmutableTarget, format!("{err}"))
}
pub fn version_conflict(err: impl Display) -> Self {
Self::new(KipErrorCode::VersionConflict, format!("{err}"))
}
pub fn execution_timeout(err: impl Display) -> Self {
Self::new(KipErrorCode::ExecutionTimeout, format!("{err}"))
}
pub fn resource_exhausted(err: impl Display) -> Self {
Self::new(KipErrorCode::ResourceExhausted, format!("{err}"))
}
pub fn internal_error(err: impl Display) -> Self {
Self::new(KipErrorCode::InternalError, format!("{err}"))
}
}
pub fn format_nom_error(input: &str, err: nom::Err<VerboseError<&str>>) -> KipError {
let message = match err {
nom::Err::Incomplete(needed) => {
format!("Parse incomplete, need more input: {needed:?}")
}
nom::Err::Error(ve) | nom::Err::Failure(ve) => format_verbose_error(input, ve),
};
KipError::invalid_syntax(message)
}
fn format_verbose_error(input: &str, ve: VerboseError<&str>) -> String {
let mut msg = String::new();
let contexts: Vec<&str> = ve
.errors
.iter()
.filter_map(|(_, kind)| match kind {
VerboseErrorKind::Context(ctx) => Some(*ctx),
_ => None,
})
.collect();
let deepest = select_primary_error(input, &ve);
let is_at_start = deepest
.map(|(slice, _)| slice.len() == input.len())
.unwrap_or(false);
if !contexts.is_empty() && !is_at_start {
msg.push_str("Parsing context: ");
let display_contexts: Vec<&str> = contexts.iter().rev().copied().collect();
let start_idx = if display_contexts.len() > 3 {
display_contexts.len() - 3
} else {
0
};
for (i, ctx) in display_contexts[start_idx..].iter().enumerate() {
if i > 0 {
msg.push_str(" > ");
}
msg.push_str(ctx);
}
msg.push('\n');
}
if let Some((slice, kind)) = deepest {
let error_desc = match kind {
VerboseErrorKind::Char(c) => {
let got = slice.chars().next();
match got {
Some(g) => format!("Expected '{}', but found '{}'", c, g),
None => format!("Expected '{}', but reached end of input", c),
}
}
VerboseErrorKind::Context(ctx) => format!("Failed to parse: {ctx}"),
VerboseErrorKind::Nom(e) => match e {
nom::error::ErrorKind::Tag => {
if is_at_start {
let got = take_first_chars(slice, 20);
format!(
"Unrecognized KIP command starting with: \"{}\". \
A KIP statement must begin with FIND, UPSERT, UPDATE, MERGE, DELETE, DESCRIBE, SEARCH, or EXPORT (case-sensitive).",
got
)
} else {
let got = take_first_chars(slice, 30);
format!("Expected a keyword, but found: \"{}\"", got)
}
}
nom::error::ErrorKind::Char => "Unexpected character".to_string(),
nom::error::ErrorKind::Alpha => {
let got = slice.chars().next();
match got {
Some(g) => {
format!("Expected a letter (a-z, A-Z), but found '{}'", g)
}
None => {
"Expected a letter (a-z, A-Z), but reached end of input".to_string()
}
}
}
nom::error::ErrorKind::Verify => "Validation check failed".to_string(),
nom::error::ErrorKind::MapRes => "Value conversion/validation failed".to_string(),
nom::error::ErrorKind::Eof => {
let remaining = take_first_chars(slice, 60);
format!(
"Unexpected trailing content after valid KIP statement: \"{}\"",
remaining
)
}
nom::error::ErrorKind::Alt => {
if is_at_start {
if slice.is_empty() {
"Empty input. A KIP statement must begin with FIND, UPSERT, UPDATE, MERGE, DELETE, DESCRIBE, SEARCH, or EXPORT.".to_string()
} else {
let got = take_first_chars(slice, 20);
format!(
"Unrecognized KIP command starting with: \"{}\". \
A KIP statement must begin with FIND, UPSERT, UPDATE, MERGE, DELETE, DESCRIBE, SEARCH, or EXPORT (case-sensitive).",
got
)
}
} else {
let got = take_first_chars(slice, 40);
format!("No valid KIP syntax matched at: \"{}\"", got)
}
}
_ => format!("Parser {:?} failed", e),
},
};
msg.push_str("Error summary: ");
msg.push_str(&error_desc);
msg.push('\n');
if let Some(expected) = expected_syntax_for_context(&contexts, deepest) {
msg.push_str("Expected here: ");
msg.push_str(&expected);
msg.push('\n');
}
let offset = input.len() - slice.len();
let (line, col) = compute_line_col(input, offset);
msg.push_str(&format!("Location: line {}, column {}\n", line, col));
if !input.is_empty() {
msg.push_str(&format_error_context_window(input, offset));
}
}
if let Some(suggestion) = generate_recovery_suggestion(input, &contexts, deepest, is_at_start) {
msg.push_str("\nRepair hint: ");
msg.push_str(&suggestion);
msg.push('\n');
}
msg
}
fn select_primary_error<'a>(
input: &str,
ve: &'a VerboseError<&'a str>,
) -> Option<&'a (&'a str, VerboseErrorKind)> {
ve.errors
.iter()
.max_by_key(|(slice, _)| input.len().saturating_sub(slice.len()))
}
fn expected_syntax_for_context(
contexts: &[&str],
deepest: Option<&(&str, VerboseErrorKind)>,
) -> Option<String> {
if context_contains(contexts, "KQL predicate term") {
return Some(
"a predicate as \"snake_case\", a predicate variable like ?predicate, alternatives \"p1\" | \"p2\", or a path \"p\"{m,n}".to_string(),
);
}
if context_contains(contexts, "KQL proposition matcher") {
return Some("(subject, \"predicate\", object) or (id: \"proposition_id\")".to_string());
}
if context_contains(contexts, "KQL concept matcher") {
return Some(
"{id: \"...\"}, {type: \"Type\"}, {name: \"Name\"}, or {type: \"Type\", name: \"Name\"}".to_string(),
);
}
if context_contains(contexts, "KQL target term") {
return Some(
"a bound variable ?var, an unnamed {type/name/id} concept clause, or an unnamed (subject, predicate, object) clause — do not attach a variable to an embedded endpoint clause; bind it in a separate clause first".to_string(),
);
}
if context_contains(contexts, "EXPECT VERSION") {
return Some(
"EXPECT VERSION <non-negative integer> (0 asserts the element does not exist yet)"
.to_string(),
);
}
if context_contains(contexts, "UPDATE expression") || context_contains(contexts, "UPDATE value")
{
return Some(
"a JSON value or an update expression: ADD(a, b) | MUL(a, b) | CLAMP(x, lo, hi) | COALESCE(x, default); operands are numbers, nested expressions, or dot-paths on the UPDATE target itself".to_string(),
);
}
if context_contains(contexts, "SEARCH MODE") {
return Some("\"keyword\", \"semantic\", or \"hybrid\" (quoted)".to_string());
}
if context_contains(contexts, "SEARCH THRESHOLD") {
return Some("a number between 0.0 and 1.0".to_string());
}
if context_contains(contexts, "FILTER") {
return Some(
"FILTER(?path == value), FILTER(expr && expr), or FILTER(IN(?path, [value, ...]))"
.to_string(),
);
}
if context_contains(contexts, "WHERE clause item") || context_is(contexts, "WHERE { ... }") {
return Some(
"one WHERE item: ?var {type: \"Type\"}, (?s, \"predicate\", ?o), FILTER(...), OPTIONAL {...}, NOT {...}, or UNION {...}".to_string(),
);
}
if context_is(contexts, "FIND( ... )")
|| context_contains(contexts, "FIND expression")
|| context_contains(contexts, "KQL aggregation expression")
{
return Some("FIND(?var[, ?var2 | COUNT(?var) ...]) followed by WHERE { ... }".to_string());
}
if context_contains(contexts, "CONCEPT [?local_handle]") {
return Some(
"CONCEPT [?handle] { {type: \"Type\", name: \"Name\"} [SET ATTRIBUTES {...}] [SET PROPOSITIONS {...}] }".to_string(),
);
}
if context_contains(contexts, "PROPOSITION [?local_handle]") {
return Some(
"PROPOSITION [?handle] { (?subject, \"predicate\", ?object) [SET ATTRIBUTES {...}] }"
.to_string(),
);
}
if context_contains(contexts, "WITH METADATA") {
return Some("WITH METADATA { source: \"...\", confidence: 0.9, ... }".to_string());
}
if context_contains(contexts, "KIP key-value map")
|| context_contains(contexts, "JSON object")
|| context_contains(contexts, "key-value pair")
{
return Some("a JSON-like object: { key: value, key2: value2 }".to_string());
}
if let Some((_, VerboseErrorKind::Char(c))) = deepest {
return Some(format!("the character `{c}`"));
}
None
}
fn context_contains(contexts: &[&str], needle: &str) -> bool {
contexts.iter().any(|ctx| ctx.contains(needle))
}
fn context_is(contexts: &[&str], expected: &str) -> bool {
contexts.contains(&expected)
}
fn compute_line_col(input: &str, offset: usize) -> (usize, usize) {
let prefix = &input[..offset];
let line = prefix.chars().filter(|c| *c == '\n').count() + 1;
let last_newline = prefix.rfind('\n').map(|p| p + 1).unwrap_or(0);
let col = input[last_newline..offset].chars().count() + 1;
(line, col)
}
fn format_error_context_window(input: &str, offset: usize) -> String {
let mut result = String::new();
let lines: Vec<&str> = input.lines().collect();
let prefix = &input[..offset];
let error_line_idx = prefix.chars().filter(|c| *c == '\n').count(); let last_newline = prefix.rfind('\n').map(|p| p + 1).unwrap_or(0);
let error_col = input[last_newline..offset].chars().count();
let start = error_line_idx.saturating_sub(2);
let end = (error_line_idx + 2).min(lines.len());
result.push_str("Context:\n");
for (i, line_content) in lines.iter().enumerate().take(end).skip(start) {
let line_num = i + 1;
let trimmed = take_first_chars(line_content, 120);
if i == error_line_idx {
result.push_str(&format!(" --> | {}\n", trimmed));
let pointer_offset = error_col;
result.push_str(&format!(" | {}^\n", " ".repeat(pointer_offset)));
} else {
result.push_str(&format!(" {:>3} | {}\n", line_num, trimmed));
}
}
result
}
fn generate_recovery_suggestion(
input: &str,
contexts: &[&str],
deepest: Option<&(&str, VerboseErrorKind)>,
is_at_start: bool,
) -> Option<String> {
if let Some(hint) = detect_common_llm_mistake(input, contexts) {
return Some(hint);
}
if is_at_start {
return Some(
"A KIP statement must start with one of: \
FIND (for queries), UPSERT/UPDATE/MERGE/DELETE (for modifications), \
or DESCRIBE/SEARCH/EXPORT (for exploration & grounding). \
All keywords are case-sensitive and must be UPPERCASE."
.to_string(),
);
}
let innermost = contexts.first().copied().unwrap_or("");
if innermost.contains("JSON string") {
return Some(
"Check for unterminated strings: ensure every '\"' has a matching closing '\"'. \
Inside JSON strings, special characters must be escaped: \
use \\\" for quotes, \\\\ for backslashes, \\n for newlines."
.to_string(),
);
}
if innermost.contains("JSON object") || innermost.contains("key-value") {
if let Some((_, VerboseErrorKind::Char(c))) = deepest {
if *c == '}' {
return Some(
"Unclosed JSON object. Ensure every '{' has a matching '}'. \
Check for missing commas between key-value pairs, \
or unterminated string values inside the object."
.to_string(),
);
}
if *c == ':' {
return Some(
"Expected ':' after key in object. Format: { key: value } or { \"key\": value }. \
Keys can be unquoted identifiers (letters, digits, underscores) or quoted strings."
.to_string(),
);
}
}
return Some(
"Check JSON object syntax: { key: value, key2: value2 }. \
Keys can be identifiers or quoted strings. \
Values can be strings, numbers, booleans, null, arrays, or nested objects. \
Trailing commas are allowed."
.to_string(),
);
}
if innermost.contains("JSON array") {
return Some(
"Check JSON array syntax: [value1, value2, ...]. \
Ensure every '[' has a matching ']'. \
Trailing commas are allowed."
.to_string(),
);
}
if innermost.contains("FIND") {
return Some(
"FIND clause syntax: FIND(?variable) or FIND(?var1, ?var2, COUNT(?var3)). \
Variables start with '?' followed by an identifier. \
Aggregation functions: COUNT, SUM, AVG, MIN, MAX."
.to_string(),
);
}
if innermost.contains("UPDATE") {
return Some(
"UPDATE syntax: UPDATE ?target SET ATTRIBUTES { key: value_or_expr, ... } SET METADATA { ... } WHERE {...} [LIMIT N]. \
At least one SET block is required. \
Update expressions: ADD(a, b), MUL(a, b), CLAMP(x, lo, hi), COALESCE(x, default); \
operands are numbers, nested expressions, or dot-paths on the target itself \
(e.g., ?target.metadata.confidence). Reserved `_` metadata keys cannot be written."
.to_string(),
);
}
if innermost.contains("MERGE") {
return Some(
"MERGE syntax: MERGE CONCEPT ?source INTO ?target WHERE { ... }. \
Each variable must bind exactly one concept node, and both nodes must have the same type."
.to_string(),
);
}
if innermost.contains("EXPORT") {
return Some(
"EXPORT syntax: EXPORT ?target WHERE { ... } [LIMIT N]. \
Serializes the matched concepts/propositions into an idempotent UPSERT capsule (read-only)."
.to_string(),
);
}
if innermost.contains("EXPECT VERSION") {
return Some(
"EXPECT VERSION syntax: EXPECT VERSION <n>, placed immediately after the identity clause \
in a CONCEPT/PROPOSITION block. <n> is a non-negative integer; 0 means create-only \
(the element must not exist yet)."
.to_string(),
);
}
if innermost.contains("WHERE") {
return Some(
"WHERE clause syntax: WHERE { <clauses> }. \
Each clause is either: a concept match (?var {type: \"T\", name: \"N\"}), \
a proposition match (?s, \"predicate\", ?o), \
FILTER(...), OPTIONAL {...}, NOT {...}, or UNION {...}."
.to_string(),
);
}
if innermost.contains("CONCEPT") && innermost.contains("?local_handle") {
return Some(
"CONCEPT block syntax: CONCEPT [?handle] { {type: \"T\", name: \"N\"} [SET ATTRIBUTES {...}] [SET PROPOSITIONS {...}] }. \
The concept matcher {type: \"...\", name: \"...\"} or {id: \"...\"} or {type: \"...\"} or {name: \"...\"} is required. \
Handle is optional: CONCEPT { ... } is also valid."
.to_string(),
);
}
if innermost.contains("PROPOSITION") && innermost.contains("?local_handle") {
return Some(
"PROPOSITION block syntax: PROPOSITION [?handle] { (subject, \"predicate\", object) [SET ATTRIBUTES {...}] }. \
Subject/object can be: ?variable, {type: \"T\", name: \"N\"}, {id: \"...\"}, or {type: \"...\"}, or {name: \"...\"}."
.to_string(),
);
}
if innermost.contains("SET ATTRIBUTES") {
return Some(
"SET ATTRIBUTES syntax: SET ATTRIBUTES { key: value, key2: value2 }. \
Keys are identifiers or quoted strings. Values are JSON values."
.to_string(),
);
}
if innermost.contains("SET PROPOSITIONS") {
return Some(
"SET PROPOSITIONS syntax: SET PROPOSITIONS { (\"predicate\", target) [WITH METADATA {...}] ... }. \
Target can be: ?variable, {type: \"T\", name: \"N\"}, {id: \"...\"}, or {type: \"...\"}, or {name: \"...\"}."
.to_string(),
);
}
if innermost.contains("WITH METADATA") {
return Some(
"WITH METADATA syntax: WITH METADATA { key: value, ... }. \
Common metadata keys: source, author, confidence (0.0-1.0), status."
.to_string(),
);
}
if innermost.contains("UPSERT") {
return Some(
"UPSERT block syntax: UPSERT { CONCEPT ... | PROPOSITION ... } [WITH METADATA {...}]. \
Must contain at least one CONCEPT or PROPOSITION block."
.to_string(),
);
}
if innermost.contains("DELETE") {
return Some(
"DELETE syntax variants: \
DELETE ATTRIBUTES {\"attr1\", \"attr2\"} FROM ?var WHERE {...}, \
DELETE METADATA {\"key1\"} FROM ?var WHERE {...}, \
DELETE PROPOSITIONS ?var WHERE {...}, \
DELETE CONCEPT ?var DETACH WHERE {...}."
.to_string(),
);
}
if innermost.contains("FILTER") {
return Some(
"FILTER syntax: FILTER(expression). \
Comparisons: ?var == value, ?var != value, ?var < value, ?var > value, ?var <= value, ?var >= value. \
Functions: CONTAINS(?var, \"text\"), STARTS_WITH(?var, \"prefix\"), ENDS_WITH(?var, \"suffix\"), REGEX(?var, \"pattern\"), IN(?var, [values]), IS_NULL(?var), IS_NOT_NULL(?var). \
Logical: expr && expr, expr || expr, !(expr)."
.to_string(),
);
}
if innermost.contains("DESCRIBE") || innermost.contains("SEARCH") {
return Some(
"META commands: DESCRIBE PRIMER | DESCRIBE DOMAINS | \
DESCRIBE CONCEPT TYPES [LIMIT N] | DESCRIBE CONCEPT TYPE \"TypeName\" | \
DESCRIBE PROPOSITION TYPES [LIMIT N] | DESCRIBE PROPOSITION TYPE \"pred\" | \
SEARCH CONCEPT|PROPOSITION \"term\" [WITH TYPE \"T\"] \
[MODE \"keyword\"|\"semantic\"|\"hybrid\"] [THRESHOLD 0.0-1.0] [LIMIT N] | \
EXPORT ?target WHERE { ... } [LIMIT N]."
.to_string(),
);
}
if innermost.contains("concept matcher") || innermost.contains("proposition matcher") {
return Some(
"Concept matcher formats: {type: \"TypeName\", name: \"Name\"} or {id: \"ID\"} or {type: \"Name\"} or {name: \"Name\"}. \
Proposition matcher formats: (?subject, \"predicate\", ?object) or (id: \"proposition_id\")."
.to_string(),
);
}
if innermost.contains("dot notation") {
return Some(
"Dot path syntax: ?variable or ?variable.field or ?variable.field.subfield. \
Variable names must start with '?' followed by a letter or underscore, \
and can contain letters, digits, and underscores. \
Path segments follow the same identifier rules."
.to_string(),
);
}
if let Some((slice, kind)) = deepest {
if slice.is_empty() {
return Some(
"Unexpected end of input. Check for unclosed brackets { }, parentheses ( ), \
or unterminated strings \"...\". The KIP statement may be incomplete."
.to_string(),
);
}
if matches!(kind, VerboseErrorKind::Nom(nom::error::ErrorKind::Eof)) {
return Some(
"There is unexpected content after a valid KIP statement. \
Each parse_kip() call should contain exactly one complete statement. \
Remove the extra text or split into separate statements."
.to_string(),
);
}
}
if contexts.is_empty() {
return Some(
"A KIP statement must start with one of: \
FIND (for queries), UPSERT/UPDATE/MERGE/DELETE (for modifications), \
or DESCRIBE/SEARCH/EXPORT (for exploration & grounding). \
Keywords are case-sensitive and must be UPPERCASE."
.to_string(),
);
}
None
}
fn detect_common_llm_mistake(input: &str, contexts: &[&str]) -> Option<String> {
let trimmed = input.trim_start();
if let Some((actual, expected)) = lowercase_keyword_correction(trimmed) {
return Some(format!(
"KIP keywords are case-sensitive. Replace `{actual}` with `{expected}`."
));
}
if trimmed.starts_with("FIND") && !contains_keyword(trimmed, "WHERE") {
return Some(
"A FIND query must include a WHERE block: FIND(...) WHERE { ... }. Add WHERE { <concept/proposition clauses> }."
.to_string(),
);
}
if context_contains(contexts, "KQL predicate term") {
return Some(
"In a proposition pattern, the predicate is usually a quoted string: (?subject, \"predicate_name\", ?object). If you intended a variable predicate, prefix it with `?`."
.to_string(),
);
}
if context_contains(contexts, "FILTER") && contains_standalone_equals(input) {
return Some(
"FILTER equality uses `==`, not `=`. For example: FILTER(?drug.name == \"Aspirin\")."
.to_string(),
);
}
if context_contains(contexts, "KQL concept matcher")
|| context_contains(contexts, "KIP key-value map")
|| context_contains(contexts, "JSON object")
{
return Some(
"Check object fields for missing commas and JSON value syntax. Concept matchers use commas, e.g. {type: \"Drug\", name: \"Aspirin\"}."
.to_string(),
);
}
None
}
fn lowercase_keyword_correction(input: &str) -> Option<(&str, &'static str)> {
let token = input
.split(|ch: char| ch.is_whitespace() || ch == '(' || ch == '{')
.next()?;
let expected = match token.to_ascii_lowercase().as_str() {
"find" => "FIND",
"where" => "WHERE",
"upsert" => "UPSERT",
"update" => "UPDATE",
"merge" => "MERGE",
"delete" => "DELETE",
"describe" => "DESCRIBE",
"search" => "SEARCH",
"export" => "EXPORT",
"filter" => "FILTER",
"optional" => "OPTIONAL",
"not" => "NOT",
"union" => "UNION",
_ => return None,
};
if token != expected {
Some((token, expected))
} else {
None
}
}
fn contains_keyword(input: &str, keyword: &str) -> bool {
input
.split(|ch: char| !ch.is_ascii_alphanumeric() && ch != '_')
.any(|token| token == keyword)
}
fn contains_standalone_equals(input: &str) -> bool {
let bytes = input.as_bytes();
for (idx, byte) in bytes.iter().enumerate() {
if *byte != b'=' {
continue;
}
let prev = idx.checked_sub(1).and_then(|i| bytes.get(i)).copied();
let next = bytes.get(idx + 1).copied();
if !matches!(prev, Some(b'=' | b'!' | b'<' | b'>')) && next != Some(b'=') {
return true;
}
}
false
}
fn take_first_chars(s: &str, n: usize) -> String {
s.chars().take(n).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parse_kip;
use nom::Needed;
fn parse_error_message(input: &str) -> String {
parse_kip(input).unwrap_err().message
}
#[test]
fn parse_error_suggests_uppercase_keyword() {
let message = parse_error_message(r#"find(?x) WHERE { ?x {type: "Drug"} }"#);
assert!(message.contains("Error summary"));
assert!(message.contains("Replace `find` with `FIND`"));
}
#[test]
fn parse_error_suggests_missing_where_block() {
let message = parse_error_message("FIND(?x)");
assert!(message.contains("WHERE block"));
assert!(message.contains("FIND(...) WHERE { ... }"));
}
#[test]
fn parse_error_suggests_quoted_predicate() {
let message = parse_error_message(r#"FIND(?x) WHERE { (?x, treats, ?y) }"#);
assert!(message.contains("predicate"));
assert!(message.contains("quoted string"));
}
#[test]
fn parse_error_suggests_double_equals_in_filter() {
let message = parse_error_message(
r#"FIND(?x) WHERE { ?x {type: "Drug"} FILTER(?x.name = "Aspirin") }"#,
);
assert!(message.contains("FILTER equality uses `==`, not `=`"));
}
#[test]
fn kip_error_code_table_and_constructors_are_consistent() {
let cases = [
(KipErrorCode::InvalidSyntax, "KIP_1001", "InvalidSyntax"),
(
KipErrorCode::InvalidIdentifier,
"KIP_1002",
"InvalidIdentifier",
),
(KipErrorCode::TypeMismatch, "KIP_2001", "TypeMismatch"),
(
KipErrorCode::ConstraintViolation,
"KIP_2002",
"ConstraintViolation",
),
(
KipErrorCode::InvalidValueType,
"KIP_2003",
"InvalidValueType",
),
(KipErrorCode::ReferenceError, "KIP_3001", "ReferenceError"),
(KipErrorCode::NotFound, "KIP_3002", "NotFound"),
(KipErrorCode::DuplicateExists, "KIP_3003", "DuplicateExists"),
(KipErrorCode::ImmutableTarget, "KIP_3004", "ImmutableTarget"),
(KipErrorCode::VersionConflict, "KIP_3005", "VersionConflict"),
(
KipErrorCode::ExecutionTimeout,
"KIP_4001",
"ExecutionTimeout",
),
(
KipErrorCode::ResourceExhausted,
"KIP_4002",
"ResourceExhausted",
),
(KipErrorCode::InternalError, "KIP_4003", "InternalError"),
];
for (code, code_str, name) in cases {
assert_eq!(code.code(), code_str);
assert_eq!(code.name(), name);
assert_eq!(code.to_string(), code_str);
assert!(!code.hint().is_empty());
}
let constructors = [
KipError::invalid_syntax("bad"),
KipError::invalid_identifier("bad"),
KipError::type_mismatch("bad"),
KipError::constraint_violation("bad"),
KipError::invalid_value_type("bad"),
KipError::reference_error("bad"),
KipError::not_found("bad"),
KipError::duplicate_exists("bad"),
KipError::immutable_target("bad"),
KipError::version_conflict("bad"),
KipError::execution_timeout("bad"),
KipError::resource_exhausted("bad"),
KipError::internal_error("bad"),
];
for (err, (code, _, name)) in constructors.into_iter().zip(cases) {
assert_eq!(err.code, code);
assert_eq!(err.code_str(), code.code());
assert_eq!(err.name(), name);
assert_eq!(err.hint(), code.hint());
assert!(err.to_string().contains("bad"));
}
}
#[test]
fn format_nom_error_handles_incomplete_and_verbose_error_kinds() {
let incomplete = format_nom_error("FIND(", nom::Err::Incomplete(Needed::Unknown));
assert_eq!(incomplete.code, KipErrorCode::InvalidSyntax);
assert!(incomplete.message.contains("Parse incomplete"));
let input = "FIND(?x) WHERE {\n ?x {type: 1}\n}";
let type_slice = &input[24..];
let verbose = VerboseError {
errors: vec![
(input, VerboseErrorKind::Context("WHERE { ... }")),
(type_slice, VerboseErrorKind::Char('"')),
],
};
let formatted = format_nom_error(input, nom::Err::Error(verbose));
assert!(formatted.message.contains("Parsing context"));
assert!(formatted.message.contains("Expected '\"'"));
assert!(formatted.message.contains("Expected here"));
assert!(formatted.message.contains("Location: line 2"));
assert!(formatted.message.contains("Context:"));
let alpha = format_verbose_error(
"!",
VerboseError {
errors: vec![("!", VerboseErrorKind::Nom(nom::error::ErrorKind::Alpha))],
},
);
assert!(alpha.contains("Expected a letter"));
let eof = format_verbose_error(
"FIND(?x) trailing",
VerboseError {
errors: vec![(
" trailing",
VerboseErrorKind::Nom(nom::error::ErrorKind::Eof),
)],
},
);
assert!(eof.contains("Unexpected trailing content"));
let validation = format_verbose_error(
"FIND(?x)",
VerboseError {
errors: vec![("", VerboseErrorKind::Nom(nom::error::ErrorKind::Verify))],
},
);
assert!(validation.contains("Validation check failed"));
let conversion = format_verbose_error(
"FIND(?x)",
VerboseError {
errors: vec![("", VerboseErrorKind::Nom(nom::error::ErrorKind::MapRes))],
},
);
assert!(conversion.contains("Value conversion/validation failed"));
}
#[test]
fn private_error_helpers_cover_context_and_hint_edges() {
assert_eq!(compute_line_col("a\nbc", 3), (2, 2));
assert!(format_error_context_window("a\nbc\ndef", 3).contains("--> | bc"));
assert_eq!(take_first_chars("ä½ å¥½ä¸–ç•Œ", 2), "ä½ å¥½");
assert_eq!(
expected_syntax_for_context(&["KQL predicate term"], None).unwrap(),
"a predicate as \"snake_case\", a predicate variable like ?predicate, alternatives \"p1\" | \"p2\", or a path \"p\"{m,n}"
);
assert!(
expected_syntax_for_context(&["KQL proposition matcher"], None)
.unwrap()
.contains("subject")
);
assert!(
expected_syntax_for_context(&["KQL concept matcher"], None)
.unwrap()
.contains("{id")
);
assert!(
expected_syntax_for_context(&["FILTER"], None)
.unwrap()
.contains("FILTER")
);
assert!(
expected_syntax_for_context(&["WHERE clause item"], None)
.unwrap()
.contains("WHERE item")
);
assert!(
expected_syntax_for_context(&["FIND expression"], None)
.unwrap()
.contains("FIND")
);
assert!(
expected_syntax_for_context(&["CONCEPT [?local_handle]"], None)
.unwrap()
.contains("CONCEPT")
);
assert!(
expected_syntax_for_context(&["PROPOSITION [?local_handle]"], None)
.unwrap()
.contains("PROPOSITION")
);
assert!(
expected_syntax_for_context(&["WITH METADATA"], None)
.unwrap()
.contains("WITH METADATA")
);
assert!(
expected_syntax_for_context(&["KIP key-value map"], None)
.unwrap()
.contains("JSON-like")
);
assert_eq!(
expected_syntax_for_context(&[], Some(&("x", VerboseErrorKind::Char(')')))).unwrap(),
"the character `)`"
);
assert!(expected_syntax_for_context(&[], None).is_none());
assert_eq!(
lowercase_keyword_correction("filter("),
Some(("filter", "FILTER"))
);
assert_eq!(lowercase_keyword_correction("FIND("), None);
assert!(contains_keyword("FIND(?x) WHERE {}", "WHERE"));
assert!(!contains_keyword("NOWHERE", "WHERE"));
assert!(contains_standalone_equals("?x = 1"));
assert!(!contains_standalone_equals("?x == 1"));
assert!(!contains_standalone_equals("?x != 1"));
let top_hint = generate_recovery_suggestion(
"bad",
&[],
Some(&("bad", VerboseErrorKind::Nom(nom::error::ErrorKind::Tag))),
true,
)
.unwrap();
assert!(top_hint.contains("must start"));
for (ctx, expected) in [
("JSON string", "unterminated strings"),
("JSON array", "array syntax"),
("FIND", "FIND clause syntax"),
("WHERE", "WHERE clause syntax"),
("CONCEPT [?local_handle]", "CONCEPT block syntax"),
("PROPOSITION [?local_handle]", "PROPOSITION block syntax"),
("SET ATTRIBUTES", "SET ATTRIBUTES syntax"),
("SET PROPOSITIONS", "SET PROPOSITIONS syntax"),
("WITH METADATA", "WITH METADATA syntax"),
("UPSERT", "UPSERT block syntax"),
("DELETE", "DELETE syntax variants"),
("FILTER", "FILTER syntax"),
("DESCRIBE", "META commands"),
("concept matcher", "Concept matcher formats"),
("dot notation", "Dot path syntax"),
] {
let hint =
generate_recovery_suggestion("FIND(?x) WHERE {}", &[ctx], None, false).unwrap();
assert!(hint.contains(expected), "{ctx}: {hint}");
}
let object_hint = generate_recovery_suggestion(
"{",
&["key-value pair"],
Some(&("", VerboseErrorKind::Char('}'))),
false,
)
.unwrap();
assert!(object_hint.contains("Unclosed JSON object"));
let colon_hint = generate_recovery_suggestion(
"{ key value }",
&["key-value pair"],
Some(&(" value", VerboseErrorKind::Char(':'))),
false,
)
.unwrap();
assert!(colon_hint.contains("Expected ':'"));
let eof_hint = generate_recovery_suggestion(
"X(",
&["other"],
Some(&("", VerboseErrorKind::Nom(nom::error::ErrorKind::Tag))),
false,
)
.unwrap();
assert!(eof_hint.contains("Unexpected end of input"));
let trailing_hint = generate_recovery_suggestion(
"X trailing",
&["other"],
Some(&(
" trailing",
VerboseErrorKind::Nom(nom::error::ErrorKind::Eof),
)),
false,
)
.unwrap();
assert!(trailing_hint.contains("unexpected content"));
}
}