hen 0.20.0

Run protocol-aware API request collections from the command line or through MCP.
Documentation
/// Parses the contents of a collection file into a Collection struct.
use std::path::Path;

use pest_derive::Parser;
use serde_json::{Value, json};

use crate::error::{
    HenDiagnostic, HenDiagnosticPosition, HenDiagnosticRange,
    HenDiagnosticSuggestion, HenError, HenErrorKind,
};

mod collection;
pub mod context;
mod declarations;
mod legacy_header;
mod oauth;
mod preprocessor;
mod protocol;
mod redaction;
mod request;
mod spans;
mod syntax;
mod variables;

pub use collection::parse_collection;
pub use collection::parse_collection_with_environment;
pub use syntax::{
    SyntaxDeclarationSummary, SyntaxFragmentIncludeSummary,
    SyntaxInspectRequestSummary, SyntaxInspectResult,
    SyntaxPositionSummary, SyntaxRangeSummary, SyntaxSummary,
    SyntaxSymbolTable, SyntaxRequestSummary, inspect_collection_editor_support,
    inspect_collection_syntax,
    inspect_collection_syntax_tolerant,
};
pub(crate) use variables::{SecretValueCache, resolve_runtime_scalar};
pub use variables::eval_shell_script;

#[derive(Parser)]
#[grammar = "src/parser/grammar.pest"]
struct CollectionParser;

pub fn preprocess_only(input: &str, working_dir: &Path) -> Result<String, String> {
    preprocessor::preprocess(input, working_dir).map_err(|err| err.to_string())
}

pub(crate) fn parse_error_to_hen_error(
    input: &str,
    working_dir: &Path,
    error: pest::error::Error<Rule>,
    path: Option<&Path>,
) -> HenError {
    if let pest::error::ErrorVariant::CustomError { message } = &error.variant {
        if let Some(diagnostic) = preprocessor::structured_diagnostic_for_message(
            input,
            working_dir,
            message,
            path,
        ) {
            return HenError::new(HenErrorKind::Parse, "Failed to parse hen file")
                .with_diagnostic(diagnostic)
                .with_detail(error.to_string());
        }

        if let Some(diagnostic) = suggested_reference_diagnostic_for_message(
            input,
            working_dir,
            &error,
            message,
            path,
        ) {
            return HenError::new(HenErrorKind::Parse, "Failed to parse hen file")
                .with_diagnostic(diagnostic)
                .with_detail(error.to_string());
        }
    }

    if let Some(diagnostic) = declarations::structured_diagnostic_for_parse_error(
        input,
        working_dir,
        &error,
        path,
    ) {
        return HenError::new(HenErrorKind::Parse, "Failed to parse hen file")
            .with_diagnostic(diagnostic)
            .with_detail(error.to_string());
    }

    HenError::from_pest_error(error, path)
}

fn suggested_reference_diagnostic_for_message(
    input: &str,
    working_dir: &Path,
    error: &pest::error::Error<Rule>,
    message: &str,
    path: Option<&Path>,
) -> Option<HenDiagnostic> {
    let preprocessed = preprocessor::preprocess(input, working_dir).ok()?;
    let preprocessed = legacy_header::normalize_legacy_collection_header(preprocessed.as_str());
    let collection = declarations::parse_request_collection(preprocessed.as_str()).ok()?;

    if let Some(missing_oauth_profile) = message
        .strip_prefix("request references unknown OAuth profile '")
        .and_then(|value| value.strip_suffix("'."))
    {
        let available_profiles = oauth_profile_names(collection.clone());
        if available_profiles.is_empty() {
            return None;
        }

        let mut diagnostic = HenDiagnostic::from_pest_error(error, path);
        add_available_names_data(&mut diagnostic, &available_profiles);
        let suggestion_range = diagnostic.location.range.clone();
        diagnostic.suggestions = available_profiles
            .iter()
            .take(3)
            .map(|name| HenDiagnosticSuggestion {
                kind: "replaceRange".to_string(),
                label: format!("Replace with '{}'", name),
                range: Some(suggestion_range.clone()),
                text: Some(name.clone()),
            })
            .collect();

        if let Some(Value::Object(map)) = diagnostic.data.as_mut() {
            map.insert("symbolName".to_string(), json!(missing_oauth_profile));
        }

        return Some(diagnostic);
    }

    if let Some((request_name, missing_dependency)) = unknown_dependency_details(message) {
        let mut available_requests = request_names(collection.clone());
        available_requests.retain(|name| name != &request_name);
        if available_requests.is_empty() {
            return None;
        }

        let mut diagnostic = HenDiagnostic::from_pest_error(error, path);
        add_available_names_data(&mut diagnostic, &available_requests);
        let suggestion_range = diagnostic.location.range.clone();
        diagnostic.suggestions = available_requests
            .iter()
            .take(3)
            .map(|name| HenDiagnosticSuggestion {
                kind: "replaceRange".to_string(),
                label: format!("Replace with '{}'", name),
                range: Some(suggestion_range.clone()),
                text: Some(name.clone()),
            })
            .collect();

        if let Some(Value::Object(map)) = diagnostic.data.as_mut() {
            map.insert("ownerName".to_string(), json!(request_name));
            map.insert("symbolName".to_string(), json!(missing_dependency));
        }

        return Some(diagnostic);
    }

    if let Some(missing_target) = message
        .strip_prefix("Unknown schema validation target '")
        .and_then(|value| value.strip_suffix('\''))
    {
        let available_targets = declaration_names(collection);
        if available_targets.is_empty() {
            return None;
        }

        let mut diagnostic = HenDiagnostic::from_pest_error(error, path);
        add_available_names_data(&mut diagnostic, &available_targets);
        let suggestion_range = schema_target_suggestion_range(
            preprocessed.as_str(),
            diagnostic.location.range.start.line,
            missing_target,
        )
        .unwrap_or_else(|| diagnostic.location.range.clone());
        diagnostic.suggestions = available_targets
            .iter()
            .take(3)
            .map(|name| HenDiagnosticSuggestion {
                kind: "replaceRange".to_string(),
                label: format!("Replace with '{}'", name),
                range: Some(suggestion_range.clone()),
                text: Some(name.clone()),
            })
            .collect();

        if let Some(Value::Object(map)) = diagnostic.data.as_mut() {
            map.insert("symbolName".to_string(), json!(missing_target));
        }

        return Some(diagnostic);
    }

    None
}

fn oauth_profile_names(collection: pest::iterators::Pair<'_, Rule>) -> Vec<String> {
    let mut names = Vec::new();

    for pair in collection.into_inner() {
        if pair.as_rule() != Rule::oauth_block {
            continue;
        }

        let Some(name) = pair
            .into_inner()
            .next()
            .map(|value| value.as_str().trim().to_string())
        else {
            continue;
        };

        names.push(name);
    }

    names.sort();
    names.dedup();
    names
}

fn request_names(collection: pest::iterators::Pair<'_, Rule>) -> Vec<String> {
    let mut names = Vec::new();

    for pair in collection.into_inner() {
        if pair.as_rule() != Rule::requests {
            continue;
        }

        for request in pair.into_inner() {
            let mut description = String::new();
            for item in request.into_inner() {
                if item.as_rule() == Rule::description {
                    description.push_str(item.as_str().trim());
                }
            }

            if description.trim().is_empty() {
                names.push("[No Description]".to_string());
            } else {
                names.push(description.trim().to_string());
            }
        }
    }

    names.sort();
    names.dedup();
    names
}

fn declaration_names(collection: pest::iterators::Pair<'_, Rule>) -> Vec<String> {
    let mut names = Vec::new();

    for pair in collection.into_inner() {
        if pair.as_rule() != Rule::declaration {
            continue;
        }

        let Some(name) = pair
            .into_inner()
            .next()
            .and_then(|value| value.into_inner().next())
            .map(|value| value.as_str().trim().to_string())
        else {
            continue;
        };

        names.push(name);
    }

    names.sort();
    names.dedup();
    names
}

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 schema_target_suggestion_range(
    source: &str,
    line: usize,
    target: &str,
) -> Option<HenDiagnosticRange> {
    let line_text = source.lines().nth(line)?;
    let operator_index = line_text.rfind("===")?;
    let after_operator = &line_text[operator_index + 3..];
    let leading_whitespace = after_operator.len() - after_operator.trim_start().len();
    let search_start = operator_index + 3 + leading_whitespace;
    let search_space = &line_text[search_start..];
    let relative_start = if search_space.starts_with(target) {
        0
    } else {
        search_space.find(target)?
    };
    let start_character = search_start + relative_start;
    let end_character = start_character + target.len();

    Some(HenDiagnosticRange {
        start: HenDiagnosticPosition {
            line,
            character: start_character,
        },
        end: HenDiagnosticPosition {
            line,
            character: end_character,
        },
    })
}

fn add_available_names_data(diagnostic: &mut HenDiagnostic, available_names: &[String]) {
    match diagnostic.data.as_mut() {
        Some(Value::Object(map)) => {
            map.insert("availableNames".to_string(), json!(available_names));
        }
        _ => {
            diagnostic.data = Some(json!({
                "availableNames": available_names,
            }));
        }
    }
}

#[cfg(test)]
mod tests;