use std::{
collections::BTreeSet,
fmt::{self, Display, Formatter},
path::Path,
};
use pest::{RuleType, error::{ErrorVariant, LineColLocation}};
use serde_json::{Value, json};
pub type HenResult<T> = Result<T, HenError>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HenErrorKind {
Cli,
Input,
Io,
Parse,
Planner,
Execution,
Benchmark,
Prompt,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HenDiagnosticSeverity {
Error,
Warning,
Information,
Hint,
}
impl HenDiagnosticSeverity {
pub fn label(self) -> &'static str {
match self {
HenDiagnosticSeverity::Error => "error",
HenDiagnosticSeverity::Warning => "warning",
HenDiagnosticSeverity::Information => "information",
HenDiagnosticSeverity::Hint => "hint",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HenDiagnosticPhase {
Parse,
Preprocess,
Inspect,
Validate,
}
impl HenDiagnosticPhase {
pub fn label(self) -> &'static str {
match self {
HenDiagnosticPhase::Parse => "parse",
HenDiagnosticPhase::Preprocess => "preprocess",
HenDiagnosticPhase::Inspect => "inspect",
HenDiagnosticPhase::Validate => "validate",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HenDiagnosticPosition {
pub line: usize,
pub character: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HenDiagnosticRange {
pub start: HenDiagnosticPosition,
pub end: HenDiagnosticPosition,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HenDiagnosticLocation {
pub path: Option<String>,
pub range: HenDiagnosticRange,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HenDiagnosticRelatedInformation {
pub message: String,
pub location: HenDiagnosticLocation,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HenDiagnosticSymbol {
pub kind: String,
pub name: String,
pub role: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HenDiagnosticSuggestion {
pub kind: String,
pub label: String,
pub range: Option<HenDiagnosticRange>,
pub text: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HenDiagnostic {
pub code: String,
pub severity: HenDiagnosticSeverity,
pub phase: HenDiagnosticPhase,
pub message: String,
pub source: &'static str,
pub location: HenDiagnosticLocation,
pub related_information: Vec<HenDiagnosticRelatedInformation>,
pub symbol: Option<HenDiagnosticSymbol>,
pub suggestions: Vec<HenDiagnosticSuggestion>,
pub data: Option<Value>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct PestDiagnosticMetadata {
code: &'static str,
phase: HenDiagnosticPhase,
source: &'static str,
}
impl HenDiagnostic {
pub fn from_pest_error<T>(err: &pest::error::Error<T>, path: Option<&Path>) -> Self
where
T: RuleType + fmt::Debug,
{
let metadata = pest_error_metadata(&err.variant);
let message = pest_error_message(&err.variant);
Self {
code: metadata.code.to_string(),
severity: HenDiagnosticSeverity::Error,
phase: metadata.phase,
message: message.clone(),
source: metadata.source,
location: HenDiagnosticLocation {
path: path.map(|value| value.display().to_string()),
range: diagnostic_range(&err.line_col),
},
related_information: Vec::new(),
symbol: pest_error_symbol(metadata.code, &message),
suggestions: Vec::new(),
data: pest_error_data(metadata.code, &message),
}
}
pub fn with_symbol(mut self, symbol: HenDiagnosticSymbol) -> Self {
self.symbol = Some(symbol);
self
}
pub fn with_data(mut self, data: Value) -> Self {
self.data = Some(data);
self
}
pub fn with_suggestions(mut self, suggestions: Vec<HenDiagnosticSuggestion>) -> Self {
self.suggestions = suggestions;
self
}
}
impl HenErrorKind {
pub fn label(self) -> &'static str {
match self {
HenErrorKind::Cli => "CLI",
HenErrorKind::Input => "Input",
HenErrorKind::Io => "IO",
HenErrorKind::Parse => "Parse",
HenErrorKind::Planner => "Planner",
HenErrorKind::Execution => "Execution",
HenErrorKind::Benchmark => "Benchmark",
HenErrorKind::Prompt => "Prompt",
}
}
}
#[derive(Debug, Clone)]
pub struct HenError {
kind: HenErrorKind,
summary: String,
details: Vec<String>,
diagnostics: Vec<HenDiagnostic>,
exit_code: i32,
}
impl HenError {
pub fn new(kind: HenErrorKind, summary: impl Into<String>) -> Self {
Self {
kind,
summary: summary.into(),
details: Vec::new(),
diagnostics: Vec::new(),
exit_code: 1,
}
}
pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
self.details.push(detail.into());
self
}
pub fn with_diagnostic(mut self, diagnostic: HenDiagnostic) -> Self {
self.diagnostics.push(diagnostic);
self
}
pub fn with_diagnostics(mut self, diagnostics: Vec<HenDiagnostic>) -> Self {
self.diagnostics.extend(diagnostics);
self
}
pub fn with_exit_code(mut self, code: i32) -> Self {
self.exit_code = code;
self
}
pub fn kind(&self) -> HenErrorKind {
self.kind
}
pub fn summary(&self) -> &str {
&self.summary
}
pub fn details(&self) -> &[String] {
&self.details
}
pub fn diagnostics(&self) -> &[HenDiagnostic] {
&self.diagnostics
}
pub fn exit_code(&self) -> i32 {
self.exit_code
}
pub fn from_pest_error<T>(err: pest::error::Error<T>, path: Option<&Path>) -> Self
where
T: RuleType + fmt::Debug,
{
let diagnostic = HenDiagnostic::from_pest_error(&err, path);
HenError::new(HenErrorKind::Parse, "Failed to parse hen file")
.with_diagnostic(diagnostic)
.with_detail(err.to_string())
}
}
impl Display for HenError {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
writeln!(f, "[{}] {}", self.kind.label(), self.summary)?;
for detail in &self.details {
writeln!(f, " - {}", detail)?;
}
Ok(())
}
}
impl std::error::Error for HenError {}
pub fn print_error(err: &HenError) {
eprint!("{}", err);
}
impl<T> From<pest::error::Error<T>> for HenError
where
T: RuleType + fmt::Debug,
{
fn from(err: pest::error::Error<T>) -> Self {
HenError::from_pest_error(err, None)
}
}
fn pest_error_metadata<T>(variant: &ErrorVariant<T>) -> PestDiagnosticMetadata {
match variant {
ErrorVariant::ParsingError { .. } => {
diagnostic_metadata("parse_error", HenDiagnosticPhase::Parse)
}
ErrorVariant::CustomError { message } => custom_pest_error_metadata(message),
}
}
fn custom_pest_error_metadata(message: &str) -> PestDiagnosticMetadata {
if message.starts_with("Failed to read import '") {
return diagnostic_metadata("fragment_import_io", HenDiagnosticPhase::Preprocess);
}
if message == "GraphQL directives require 'protocol = graphql'" {
return protocol_diagnostic_metadata("graphql_protocol_required");
}
if message == "MCP directives require 'protocol = mcp'" {
return protocol_diagnostic_metadata("mcp_protocol_required");
}
if message == "WebSocket directives require 'protocol = ws'" {
return protocol_diagnostic_metadata("ws_protocol_required");
}
if message == "SSE directives require 'protocol = sse'" {
return protocol_diagnostic_metadata("sse_protocol_required");
}
if message == "request is missing a URL" {
return diagnostic_metadata("missing_request_url", HenDiagnosticPhase::Validate);
}
if message == "request is missing an HTTP method" {
return diagnostic_metadata("missing_request_method", HenDiagnosticPhase::Validate);
}
if message == "schema and scalar declarations must appear before the first ---" {
return diagnostic_metadata("misplaced_declaration", HenDiagnosticPhase::Validate);
}
if message.starts_with("Unknown environment '") {
return diagnostic_metadata("unknown_environment", HenDiagnosticPhase::Validate);
}
if message.starts_with("request references unknown OAuth profile '") {
return diagnostic_metadata("unknown_oauth_profile", HenDiagnosticPhase::Validate);
}
if message.starts_with("Duplicate environment '") {
return diagnostic_metadata("duplicate_environment", HenDiagnosticPhase::Validate);
}
if message.starts_with("Duplicate OAuth profile '") {
return diagnostic_metadata("duplicate_oauth_profile", HenDiagnosticPhase::Validate);
}
if message.starts_with("OAuth profile '") && message.contains(" uses unsupported field '") {
return diagnostic_metadata("unsupported_oauth_field", HenDiagnosticPhase::Validate);
}
if message.starts_with("OAuth profile '") && message.contains(" defines field '") && message.contains(" more than once.") {
return diagnostic_metadata("duplicate_oauth_field", HenDiagnosticPhase::Validate);
}
if message.starts_with("OAuth profile '") && message.contains(" defines param '") && message.contains(" more than once.") {
return diagnostic_metadata("duplicate_oauth_param", HenDiagnosticPhase::Validate);
}
if message.starts_with("OAuth profile '") && message.contains(" maps field '") && message.contains(" more than once.") {
return diagnostic_metadata("duplicate_oauth_mapping_source", HenDiagnosticPhase::Validate);
}
if message.starts_with("OAuth profile '") && message.contains(" maps more than one field into '") {
return diagnostic_metadata("duplicate_oauth_mapping_target", HenDiagnosticPhase::Validate);
}
if message.starts_with("OAuth profile '") && message.contains(" requires 'grant = ...'.") {
return diagnostic_metadata("missing_oauth_grant", HenDiagnosticPhase::Validate);
}
if message.starts_with("OAuth profile '")
&& message.contains(" must define exactly one of 'issuer' or 'token_url'.")
{
return diagnostic_metadata("invalid_oauth_endpoint_config", HenDiagnosticPhase::Validate);
}
if message.starts_with("OAuth profile '")
&& message.contains(" with grant '")
&& message.contains(" requires '")
{
return diagnostic_metadata("missing_oauth_field", HenDiagnosticPhase::Validate);
}
if message.starts_with("OAuth profile '") && message.contains(" uses unsupported grant '") {
return diagnostic_metadata("unsupported_oauth_grant", HenDiagnosticPhase::Validate);
}
if message.starts_with("session-backed request omitted its method/URL, but session '") {
return diagnostic_metadata(
"missing_session_inherited_target",
HenDiagnosticPhase::Validate,
);
}
if message.starts_with("session '") && message.contains(" already uses protocol '") {
return protocol_diagnostic_metadata("session_protocol_conflict");
}
if message.starts_with("unsupported protocol '") {
return protocol_diagnostic_metadata("unsupported_protocol");
}
if message == "GraphQL requests currently require POST" {
return protocol_diagnostic_metadata("graphql_requires_post");
}
if message == "GraphQL requests do not support form fields" {
return protocol_diagnostic_metadata("graphql_form_fields_unsupported");
}
if message == "GraphQL requests require a ~~~graphql document block" {
return protocol_diagnostic_metadata("graphql_missing_document");
}
if message.starts_with("MCP-over-HTTP requests currently require POST") {
return protocol_diagnostic_metadata("mcp_requires_post");
}
if message.starts_with("MCP-over-HTTP requests do not support explicit body") {
return protocol_diagnostic_metadata("mcp_body_unsupported");
}
if message == "MCP requests require 'call = ...'" {
return protocol_diagnostic_metadata("mcp_missing_call");
}
if message == "'tool' and 'arguments' are only valid with 'call = tools/call'" {
return protocol_diagnostic_metadata("mcp_tool_arguments_invalid_for_call");
}
if message.starts_with("'call = tools/list' does not accept ") {
return protocol_diagnostic_metadata("mcp_tools_list_directives_unsupported");
}
if message.starts_with("'call = resources/list' does not accept ") {
return protocol_diagnostic_metadata("mcp_resources_list_directives_unsupported");
}
if message == "initialize override directives are only valid with 'call = initialize'" {
return protocol_diagnostic_metadata("mcp_initialize_overrides_invalid_for_call");
}
if message == "'call = tools/call' requires 'tool = ...'" {
return protocol_diagnostic_metadata("mcp_missing_tool");
}
if message.starts_with("unsupported MCP call '") {
return protocol_diagnostic_metadata("unsupported_mcp_call");
}
if message == "SSE requests require 'session = ...'" {
return protocol_diagnostic_metadata("sse_missing_session");
}
if message == "SSE requests currently require GET" {
return protocol_diagnostic_metadata("sse_requires_get");
}
if message.starts_with("SSE requests do not support explicit body") {
return protocol_diagnostic_metadata("sse_body_unsupported");
}
if message == "SSE receive steps require 'within = ...'" {
return protocol_diagnostic_metadata("sse_missing_within");
}
if message.starts_with("invalid within duration '") {
return protocol_diagnostic_metadata("invalid_within_duration");
}
if message == "'within = ...' is only valid with 'receive'" {
return protocol_diagnostic_metadata("within_requires_receive");
}
if message == "WebSocket requests require 'session = ...'" {
return protocol_diagnostic_metadata("ws_missing_session");
}
if message == "WebSocket requests currently require GET" {
return protocol_diagnostic_metadata("ws_requires_get");
}
if message == "WebSocket requests do not support form fields" {
return protocol_diagnostic_metadata("ws_form_fields_unsupported");
}
if message == "WebSocket requests cannot combine 'send = ...' with 'receive'" {
return protocol_diagnostic_metadata("ws_send_receive_conflict");
}
if message == "WebSocket receive steps require 'within = ...'" {
return protocol_diagnostic_metadata("ws_missing_within");
}
if message == "WebSocket send steps require a body block"
|| (message.starts_with("WebSocket send kind '")
&& message.ends_with("' requires a body block"))
{
return protocol_diagnostic_metadata("ws_missing_body");
}
if message.starts_with("WebSocket ") && message.contains(" do not support explicit body or content type blocks") {
return protocol_diagnostic_metadata("ws_body_unsupported");
}
if message.starts_with("unsupported WebSocket send kind '") {
return protocol_diagnostic_metadata("unsupported_ws_send_kind");
}
if message.starts_with("unsupported WebSocket body block type '") {
return protocol_diagnostic_metadata("unsupported_ws_body_content_type");
}
if message.starts_with("WebSocket send kind '") && message.contains(" conflicts with body block type '") {
return protocol_diagnostic_metadata("conflicting_ws_send_kind");
}
if message.starts_with("invalid WebSocket JSON payload:") {
return protocol_diagnostic_metadata("invalid_ws_json_payload");
}
if message.starts_with("invalid GraphQL variables JSON:") {
return protocol_diagnostic_metadata("invalid_graphql_variables_json");
}
if message.starts_with("invalid MCP ") && message.contains(" JSON:") {
return protocol_diagnostic_metadata("invalid_mcp_json");
}
if message.starts_with("MCP ") && message.ends_with(" must be a JSON object") {
return protocol_diagnostic_metadata("invalid_mcp_json_object");
}
if message == "scalar expressions can only declare one base type" {
return diagnostic_metadata("multiple_scalar_base_types", HenDiagnosticPhase::Validate);
}
if message == "scalar declaration must define a base type or predicate" {
return diagnostic_metadata("missing_scalar_base_or_predicate", HenDiagnosticPhase::Validate);
}
if message.starts_with("invalid ") && message.ends_with("() predicate") {
return diagnostic_metadata("invalid_scalar_predicate", HenDiagnosticPhase::Validate);
}
if message.contains("() requires min..max syntax") {
return diagnostic_metadata("invalid_scalar_bounds_syntax", HenDiagnosticPhase::Validate);
}
if message.contains("() requires at least one bound") {
return diagnostic_metadata("missing_scalar_bounds", HenDiagnosticPhase::Validate);
}
if message == "range() bounds must be numbers" {
return diagnostic_metadata("invalid_range_bounds", HenDiagnosticPhase::Validate);
}
if message.ends_with(" is reserved and cannot be redefined") {
return diagnostic_metadata("reserved_declaration_name", HenDiagnosticPhase::Validate);
}
if message.ends_with(" is already defined") {
return diagnostic_metadata("duplicate_declaration_name", HenDiagnosticPhase::Validate);
}
if message.contains(" references unknown validation target ") {
return diagnostic_metadata("unknown_validation_target", HenDiagnosticPhase::Validate);
}
if message.starts_with("scalar ") && message.contains(" cannot use schema ") {
return diagnostic_metadata(
"invalid_scalar_base_reference",
HenDiagnosticPhase::Validate,
);
}
if message.starts_with("schema declarations contain a circular reference:") {
return diagnostic_metadata("circular_schema_reference", HenDiagnosticPhase::Validate);
}
if message.starts_with("Unknown schema validation target '") {
return diagnostic_metadata(
"unknown_schema_validation_target",
HenDiagnosticPhase::Validate,
);
}
if message.starts_with("Environment '")
&& message.contains(" overrides unknown or non-scalar variable '")
{
return diagnostic_metadata("unknown_environment_variable", HenDiagnosticPhase::Validate);
}
if message.starts_with("Environment '")
&& message.contains(" defines variable '")
&& message.contains(" with unsupported value '")
{
return diagnostic_metadata("invalid_environment_value", HenDiagnosticPhase::Validate);
}
if message.starts_with("Secret reference in '") && message.contains(" has invalid syntax '") {
return diagnostic_metadata("invalid_secret_reference", HenDiagnosticPhase::Validate);
}
if message.starts_with("Secret reference in '") && message.contains(" uses unsupported provider '") {
return diagnostic_metadata("unsupported_secret_provider", HenDiagnosticPhase::Validate);
}
if message.starts_with("Secret reference in '")
&& message.contains(" requires environment variable '")
{
return diagnostic_metadata("missing_env_secret", HenDiagnosticPhase::Validate);
}
if message.starts_with("Secret reference in '") && message.contains(" failed to read file '") {
return diagnostic_metadata("file_secret_io", HenDiagnosticPhase::Validate);
}
if message.starts_with("Request '")
&& message.contains(" references ")
&& message.contains(" array variables but the limit is ")
{
return diagnostic_metadata("too_many_array_variables", HenDiagnosticPhase::Validate);
}
if message.starts_with("Request '")
&& message.contains(" references array variable '")
&& message.contains(" which is not defined as an array.")
{
return diagnostic_metadata("missing_array_values", HenDiagnosticPhase::Validate);
}
if message.starts_with("Request '")
&& message.contains(" references array variable '")
&& message.contains(" but it contains no values.")
{
return diagnostic_metadata("empty_array_values", HenDiagnosticPhase::Validate);
}
if message.starts_with("Request '")
&& message.contains(" expands into ")
&& message.contains(" combinations which exceeds the limit of ")
{
return diagnostic_metadata("too_many_combinations", HenDiagnosticPhase::Validate);
}
if message.starts_with("Request '")
&& message.contains(" defines array variable '")
&& message.contains(" with unsupported value '")
{
return diagnostic_metadata("invalid_array_value", HenDiagnosticPhase::Validate);
}
if message.starts_with("Request '")
&& message.contains(" depends on '")
&& message.contains(" expands into multiple iterations.")
{
return diagnostic_metadata(
"mapped_request_dependency_unsupported",
HenDiagnosticPhase::Validate,
);
}
if message.starts_with("Request '") && message.contains(" declares unknown dependency '") {
return diagnostic_metadata("unknown_dependency", HenDiagnosticPhase::Validate);
}
if message.starts_with("Request '") && message.contains(" has invalid fragment guard '") {
return diagnostic_metadata("invalid_fragment_guard", HenDiagnosticPhase::Validate);
}
if message.starts_with("Request '") && message.contains(" failed to load fragment '") {
return diagnostic_metadata("fragment_load_error", HenDiagnosticPhase::Validate);
}
if message.starts_with("Request '") && message.contains(" failed to parse fragment '") {
return diagnostic_metadata("fragment_parse_error", HenDiagnosticPhase::Validate);
}
if message.starts_with("Request '") && message.contains(" cannot include line '") {
return diagnostic_metadata("fragment_unsupported_line", HenDiagnosticPhase::Validate);
}
if message.starts_with("Request '")
&& message.contains(" requires prompt '")
&& message.contains(" but no input was provided")
{
return diagnostic_metadata("missing_prompt_input", HenDiagnosticPhase::Validate);
}
if message.starts_with("Request '") && message.contains(" defines invalid ") {
return diagnostic_metadata("invalid_reliability_value", HenDiagnosticPhase::Validate);
}
if message.starts_with("Missing value for prompt '") {
return diagnostic_metadata("missing_prompt_input", HenDiagnosticPhase::Validate);
}
diagnostic_metadata("parse_custom_error", HenDiagnosticPhase::Parse)
}
fn diagnostic_metadata(
code: &'static str,
phase: HenDiagnosticPhase,
) -> PestDiagnosticMetadata {
PestDiagnosticMetadata {
code,
phase,
source: match phase {
HenDiagnosticPhase::Preprocess => "hen.preprocess",
HenDiagnosticPhase::Parse
| HenDiagnosticPhase::Inspect
| HenDiagnosticPhase::Validate => "hen.parser",
},
}
}
fn protocol_diagnostic_metadata(code: &'static str) -> PestDiagnosticMetadata {
PestDiagnosticMetadata {
code,
phase: HenDiagnosticPhase::Validate,
source: "hen.protocol",
}
}
fn pest_error_symbol(code: &str, message: &str) -> Option<HenDiagnosticSymbol> {
match code {
"unknown_oauth_profile" => first_quoted_value(message).map(|name| HenDiagnosticSymbol {
kind: "oauthProfile".to_string(),
name,
role: "reference".to_string(),
}),
"unknown_dependency" => {
unknown_dependency_details(message).map(|(_, dependency)| HenDiagnosticSymbol {
kind: "request".to_string(),
name: dependency,
role: "reference".to_string(),
})
}
"unknown_environment" => first_quoted_value(message).map(|name| HenDiagnosticSymbol {
kind: "environment".to_string(),
name,
role: "reference".to_string(),
}),
_ => None,
}
}
fn pest_error_data(code: &str, message: &str) -> Option<Value> {
match code {
"unknown_oauth_profile" => Some(json!({
"expectedKinds": ["oauthProfile"],
"symbolName": first_quoted_value(message),
})),
"unknown_dependency" => unknown_dependency_details(message).map(|(request, dependency)| {
json!({
"expectedKinds": ["request"],
"ownerName": request,
"symbolName": dependency,
})
}),
"unknown_schema_validation_target" => Some(json!({
"expectedKinds": ["schema", "scalar"],
"symbolName": first_quoted_value(message),
})),
"unknown_environment" => Some(json!({
"expectedKinds": ["environment"],
"symbolName": first_quoted_value(message),
})),
"missing_array_values" => request_variable_data(
message,
"' references array variable '",
"' which is not defined as an array.",
),
"empty_array_values" => request_variable_data(
message,
"' references array variable '",
"' but it contains no values.",
).map(|mut data| {
data["issue"] = Value::String("emptyArray".to_string());
data
}),
"invalid_array_value" => request_variable_value_data(
message,
"' defines array variable '",
"' with unsupported value '",
"'.",
),
"too_many_combinations" => too_many_combinations_data(message),
"invalid_secret_reference" => source_value_data(
message,
"Secret reference in '",
"' has invalid syntax '",
"'. Use secret.env(\"NAME\") or secret.file(\"PATH\").",
).map(|mut data| {
data["supportedValues"] = json!(["secret.env(\"NAME\")", "secret.file(\"PATH\")"]);
data
}),
"unsupported_secret_provider" => source_value_data(
message,
"Secret reference in '",
"' uses unsupported provider '",
"'. Supported providers are env and file.",
).map(|mut data| {
data["supportedValues"] = json!(["env", "file"]);
data["provider"] = data["invalidValue"].clone();
data
}),
"missing_env_secret" => source_value_data(
message,
"Secret reference in '",
"' requires environment variable '",
"' but it is not set.",
).map(|mut data| {
data["requiredEnvironmentVariable"] = data["invalidValue"].clone();
data["directiveName"] = Value::String("secret.env".to_string());
data
}),
"file_secret_io" => source_value_reason_data(
message,
"Secret reference in '",
"' failed to read file '",
"': ",
".",
).map(|mut data| {
data["directiveName"] = Value::String("secret.file".to_string());
data
}),
"missing_prompt_input" => missing_prompt_input_data(message),
"invalid_reliability_value" => invalid_reliability_value_data(message),
_ => protocol_error_data(code, message),
}
}
fn request_variable_data(
message: &str,
middle_prefix: &str,
suffix: &str,
) -> Option<Value> {
let remainder = message.strip_prefix("Request '")?;
let (request, remainder) = remainder.split_once(middle_prefix)?;
let variable = remainder.strip_suffix(suffix)?;
Some(json!({
"ownerName": request,
"variableName": variable,
}))
}
fn request_variable_value_data(
message: &str,
middle_prefix: &str,
value_prefix: &str,
suffix: &str,
) -> Option<Value> {
let remainder = message.strip_prefix("Request '")?;
let (request, remainder) = remainder.split_once(middle_prefix)?;
let (variable, value) = remainder.split_once(value_prefix)?;
let value = value.strip_suffix(suffix)?;
Some(json!({
"ownerName": request,
"variableName": variable,
"invalidValue": value,
}))
}
fn too_many_combinations_data(message: &str) -> Option<Value> {
let remainder = message.strip_prefix("Request '")?;
let (request, remainder) = remainder.split_once("' expands into ")?;
let (count, limit) = remainder.split_once(" combinations which exceeds the limit of ")?;
let limit = limit.strip_suffix('.')?;
Some(json!({
"ownerName": request,
"count": count.parse::<usize>().ok(),
"limit": limit.parse::<usize>().ok(),
}))
}
fn source_value_data(
message: &str,
source_prefix: &str,
middle_prefix: &str,
suffix: &str,
) -> Option<Value> {
let remainder = message.strip_prefix(source_prefix)?;
let (source, remainder) = remainder.split_once(middle_prefix)?;
let value = remainder.strip_suffix(suffix)?;
Some(json!({
"sourceName": source,
"invalidValue": value,
}))
}
fn source_value_reason_data(
message: &str,
source_prefix: &str,
middle_prefix: &str,
reason_prefix: &str,
suffix: &str,
) -> Option<Value> {
let remainder = message.strip_prefix(source_prefix)?;
let (source, remainder) = remainder.split_once(middle_prefix)?;
let (value, reason) = remainder.split_once(reason_prefix)?;
let reason = reason.strip_suffix(suffix)?;
Some(json!({
"sourceName": source,
"invalidValue": value,
"reason": reason,
}))
}
fn missing_prompt_input_data(message: &str) -> Option<Value> {
if let Some(remainder) = message.strip_prefix("Request '") {
let (request, remainder) = remainder.split_once("' requires prompt '")?;
let (prompt, remainder) = remainder.split_once("' but no input was provided")?;
let default = remainder
.strip_prefix(" (default: ")
.and_then(|value| value.strip_suffix(")."))
.map(str::to_string);
return Some(json!({
"ownerName": request,
"promptName": prompt,
"defaultValue": default,
}));
}
let remainder = message.strip_prefix("Missing value for prompt '")?;
let (prompt, remainder) = remainder.split_once("'")?;
let default = remainder
.strip_prefix(" (default: ")
.and_then(|value| value.strip_suffix(")"))
.map(str::to_string);
Some(json!({
"promptName": prompt,
"defaultValue": default,
}))
}
fn invalid_reliability_value_data(message: &str) -> Option<Value> {
let remainder = message.strip_prefix("Request '")?;
let (request, remainder) = remainder.split_once("' defines invalid ")?;
let (field, remainder) = remainder.split_once(" '")?;
let (value, reason) = remainder.split_once("': ")?;
let reason = reason.strip_suffix('.')?;
Some(json!({
"ownerName": request,
"fieldName": field,
"invalidValue": value,
"reason": reason,
}))
}
fn protocol_error_data(code: &str, message: &str) -> Option<Value> {
match code {
"graphql_protocol_required" => Some(json!({
"protocol": "graphql",
"expectedProtocol": "graphql",
"requiredDirectives": ["protocol"],
"directiveFamilies": ["graphql"],
})),
"graphql_requires_post" => Some(json!({
"protocol": "graphql",
"expectedMethod": "POST",
})),
"mcp_protocol_required" => Some(json!({
"protocol": "mcp",
"expectedProtocol": "mcp",
"requiredDirectives": ["protocol"],
"directiveFamilies": ["mcp"],
})),
"mcp_requires_post" => Some(json!({
"protocol": "mcp",
"expectedMethod": "POST",
})),
"sse_protocol_required" => Some(json!({
"protocol": "sse",
"expectedProtocol": "sse",
"requiredDirectives": ["protocol"],
"directiveFamilies": ["sse"],
})),
"sse_requires_get" => Some(json!({
"protocol": "sse",
"expectedMethod": "GET",
})),
"ws_protocol_required" => Some(json!({
"protocol": "ws",
"expectedProtocol": "ws",
"requiredDirectives": ["protocol"],
"directiveFamilies": ["ws"],
})),
"ws_requires_get" => Some(json!({
"protocol": "ws",
"expectedMethod": "GET",
})),
"unsupported_protocol" => Some(json!({
"directiveName": "protocol",
"invalidValue": first_quoted_value(message),
"supportedValues": ["http", "graphql", "mcp", "sse", "ws"],
})),
"session_protocol_conflict" => session_protocol_conflict_details(message).map(|(session_name, expected_protocol, conflicting_protocol)| json!({
"sessionName": session_name,
"expectedProtocol": expected_protocol,
"conflictingProtocol": conflicting_protocol,
})),
"graphql_missing_document" => Some(json!({
"protocol": "graphql",
"requiredBlocks": ["graphqlDocument"],
"supportedBlockTypes": ["graphql"],
"insertBlockTitle": "Insert GraphQL document block",
"replacementOpeningFence": "~~~graphql",
"replacementBodyText": "query {\n field\n}",
})),
"graphql_form_fields_unsupported" => Some(json!({
"protocol": "graphql",
"cleanupTargets": ["formFields"],
})),
"mcp_body_unsupported" => Some(json!({
"protocol": "mcp",
"cleanupTargets": ["bodyBlock", "formFields"],
})),
"mcp_missing_call" => Some(json!({
"protocol": "mcp",
"requiredDirectives": ["call"],
"supportedValues": ["initialize", "tools/list", "resources/list", "tools/call"],
})),
"mcp_tool_arguments_invalid_for_call" => Some(json!({
"protocol": "mcp",
"requiredDirectives": ["call"],
"requiredCall": "tools/call",
})),
"mcp_tools_list_directives_unsupported" => mcp_call_conflict_data(message),
"mcp_resources_list_directives_unsupported" => mcp_call_conflict_data(message),
"mcp_initialize_overrides_invalid_for_call" => Some(json!({
"protocol": "mcp",
"requiredDirectives": ["call"],
"requiredCall": "initialize",
})),
"unsupported_mcp_call" => Some(json!({
"protocol": "mcp",
"requiredDirectives": ["call"],
"supportedValues": ["initialize", "tools/list", "resources/list", "tools/call"],
})),
"mcp_missing_tool" => Some(json!({
"protocol": "mcp",
"requiredDirectives": ["tool"],
"requiredCall": "tools/call",
"directiveName": "tool",
"replacementValue": "exampleTool",
})),
"sse_missing_session" => Some(json!({
"protocol": "sse",
"requiredDirectives": ["session"],
"directiveName": "session",
"replacementValue": "exampleSession",
})),
"sse_body_unsupported" => Some(json!({
"protocol": "sse",
"cleanupTargets": ["bodyBlock"],
})),
"ws_missing_session" => Some(json!({
"protocol": "ws",
"requiredDirectives": ["session"],
"directiveName": "session",
"replacementValue": "exampleSession",
})),
"ws_form_fields_unsupported" => Some(json!({
"protocol": "ws",
"cleanupTargets": ["formFields"],
})),
"sse_missing_within" => Some(json!({
"protocol": "sse",
"requiredDirectives": ["within"],
"requiredAction": "receive",
"directiveName": "within",
"replacementValue": "30s",
})),
"ws_missing_within" => Some(json!({
"protocol": "ws",
"requiredDirectives": ["within"],
"requiredAction": "receive",
"directiveName": "within",
"replacementValue": "30s",
})),
"within_requires_receive" => Some(json!({
"requiredDirectives": ["receive"],
"requiredFlagDirective": "receive",
"anchorDirectiveNames": ["within"],
})),
"ws_missing_body" => ws_missing_body_data(message),
"ws_body_unsupported" => Some(json!({
"protocol": "ws",
"cleanupTargets": ["bodyBlock"],
})),
"ws_send_receive_conflict" => Some(json!({
"protocol": "ws",
"conflictingDirectives": ["send", "receive"],
})),
"unsupported_ws_send_kind" => Some(json!({
"protocol": "ws",
"directiveName": "send",
"invalidValue": first_quoted_value(message),
"supportedValues": ["text", "json"],
})),
"unsupported_ws_body_content_type" => Some(json!({
"protocol": "ws",
"blockKind": "body",
"invalidValue": first_quoted_value(message),
"supportedBlockTypes": ["text", "json"],
})),
"conflicting_ws_send_kind" => ws_send_kind_conflict_details(message).and_then(|(current_value, body_content_type)| {
let expected_value = ws_send_kind_for_body_content_type(body_content_type.as_str())?;
Some(json!({
"protocol": "ws",
"directiveName": "send",
"currentValue": current_value,
"expectedValue": expected_value,
"bodyContentType": body_content_type,
}))
}),
"invalid_ws_json_payload" => Some(json!({
"protocol": "ws",
"blockKind": "body",
"replacementBodyText": "{\n \"type\": \"message\"\n}",
})),
"invalid_graphql_variables_json" => Some(json!({
"protocol": "graphql",
"directiveName": "variables",
"replacementValue": "{}",
})),
"invalid_mcp_json" => mcp_invalid_json_field_name(message).map(|directive_name| json!({
"protocol": "mcp",
"directiveName": directive_name,
"replacementValue": "{}",
})),
"invalid_mcp_json_object" => mcp_json_object_field_name(message).map(|directive_name| json!({
"protocol": "mcp",
"directiveName": directive_name,
"replacementValue": "{}",
})),
_ => None,
}
}
fn first_quoted_value(message: &str) -> Option<String> {
let start = message.find('\'')?;
let end = message[start + 1..].find('\'')? + start + 1;
Some(message[start + 1..end].to_string())
}
fn quoted_values(message: &str) -> Vec<String> {
let mut values = Vec::new();
let mut remainder = message;
while let Some(start) = remainder.find('\'') {
let after_start = &remainder[start + 1..];
let Some(end) = after_start.find('\'') else {
break;
};
values.push(after_start[..end].to_string());
remainder = &after_start[end + 1..];
}
values
}
fn ws_send_kind_conflict_details(message: &str) -> Option<(String, String)> {
let values = quoted_values(message);
if values.len() < 2 {
return None;
}
Some((values[0].clone(), values[1].clone()))
}
fn ws_missing_body_data(message: &str) -> Option<Value> {
let send_kind = first_quoted_value(message)?;
let (insert_block_title, replacement_opening_fence, replacement_body_text) =
match send_kind.as_str() {
"json" => (
"Insert WebSocket JSON body block",
"~~~json",
"{\n \"type\": \"message\"\n}",
),
"text" => (
"Insert WebSocket body block",
"~~~",
"message payload",
),
_ => return None,
};
Some(json!({
"protocol": "ws",
"requiredBlocks": ["body"],
"requiredDirectives": ["send"],
"sendKind": send_kind,
"insertBlockTitle": insert_block_title,
"replacementOpeningFence": replacement_opening_fence,
"replacementBodyText": replacement_body_text,
}))
}
fn session_protocol_conflict_details(message: &str) -> Option<(String, String, String)> {
let values = quoted_values(message);
if values.len() < 3 {
return None;
}
Some((values[0].clone(), values[1].clone(), values[2].clone()))
}
fn mcp_invalid_json_field_name(message: &str) -> Option<String> {
let remainder = message.strip_prefix("invalid MCP ")?;
let end = remainder.find(" JSON:")?;
Some(remainder[..end].trim().to_string())
}
fn mcp_json_object_field_name(message: &str) -> Option<String> {
let remainder = message.strip_prefix("MCP ")?;
let field_name = remainder.strip_suffix(" must be a JSON object")?;
Some(field_name.trim().to_string())
}
fn mcp_call_conflict_data(message: &str) -> Option<Value> {
let replacements = mcp_call_conflict_replacements(message);
if replacements.is_empty() {
return None;
}
if replacements.len() == 1 {
return Some(json!({
"protocol": "mcp",
"requiredDirectives": ["call"],
"requiredCall": replacements[0],
}));
}
Some(json!({
"protocol": "mcp",
"requiredDirectives": ["call"],
"supportedValues": replacements,
}))
}
fn mcp_call_conflict_replacements(message: &str) -> Vec<String> {
quoted_values(message)
.into_iter()
.skip(1)
.filter_map(|value| value.strip_prefix("call = ").map(str::to_string))
.collect()
}
fn ws_send_kind_for_body_content_type(value: &str) -> Option<&'static str> {
match value.trim() {
"json" | "application/json" => Some("json"),
"text" | "text/plain" => Some("text"),
_ => None,
}
}
fn unknown_dependency_details(message: &str) -> Option<(String, String)> {
let remainder = message.strip_prefix("Request '")?;
let (request, remainder) = remainder.split_once("' declares unknown dependency '")?;
let dependency = remainder.strip_suffix("'.")?;
Some((request.to_string(), dependency.to_string()))
}
fn pest_error_message<T>(variant: &ErrorVariant<T>) -> String
where
T: fmt::Debug,
{
match variant {
ErrorVariant::CustomError { message } => message.clone(),
ErrorVariant::ParsingError {
positives,
negatives,
} => {
let expected = format_rule_list(positives);
let unexpected = format_rule_list(negatives);
if !expected.is_empty() {
return format!("Unexpected input; expected {expected}");
}
if !unexpected.is_empty() {
return format!("Unexpected input; disallowed {unexpected}");
}
"Unexpected input".to_string()
}
}
}
fn format_rule_list<T>(rules: &[T]) -> String
where
T: fmt::Debug,
{
let values = rules
.iter()
.map(|rule| format!("{:?}", rule))
.collect::<BTreeSet<_>>()
.into_iter()
.collect::<Vec<_>>();
match values.len() {
0 => String::new(),
1 => values[0].clone(),
_ => values.join(", "),
}
}
fn diagnostic_range(line_col: &LineColLocation) -> HenDiagnosticRange {
let range = match line_col {
LineColLocation::Pos((line, character)) => HenDiagnosticRange {
start: diagnostic_position(*line, *character),
end: diagnostic_position(*line, character.saturating_add(1)),
},
LineColLocation::Span((start_line, start_character), (end_line, end_character)) => {
HenDiagnosticRange {
start: diagnostic_position(*start_line, *start_character),
end: diagnostic_position(*end_line, *end_character),
}
}
};
ensure_non_empty_range(range)
}
fn diagnostic_position(line: usize, character: usize) -> HenDiagnosticPosition {
HenDiagnosticPosition {
line: line.saturating_sub(1),
character: character.saturating_sub(1),
}
}
fn ensure_non_empty_range(mut range: HenDiagnosticRange) -> HenDiagnosticRange {
if range.end.line < range.start.line
|| (range.end.line == range.start.line && range.end.character <= range.start.character)
{
range.end.line = range.start.line;
range.end.character = range.start.character.saturating_add(1);
}
range
}