hurl-lsp 0.1.11

Language Server Protocol implementation for Hurl
use crate::syntax::{
    canonical_section_name, is_http_method, is_identifier, is_known_section, section_label,
    section_name_from_line, variable_placeholders, visible_variables_before_line,
};
use hurl_core::error::DisplaySourceError;
use std::collections::BTreeSet;
use tower_lsp::lsp_types::{Diagnostic, DiagnosticSeverity, Position, Range};

#[derive(Clone, Debug, Default)]
pub struct Entry {
    pub method: String,
    pub path: String,
    pub line: u32,
}

#[derive(Clone, Debug, Default)]
pub struct ParsedDocument {
    pub entries: Vec<Entry>,
}

pub fn parse_document(text: &str) -> ParsedDocument {
    let mut entries = Vec::new();

    for (line_idx, raw_line) in text.lines().enumerate() {
        let line = raw_line.trim();
        if line.is_empty() || line.starts_with('#') || line.starts_with("HTTP ") {
            continue;
        }

        let mut parts = line.split_whitespace();
        let Some(first) = parts.next() else { continue };
        let Some(second) = parts.next() else { continue };

        if is_http_method(first) {
            entries.push(Entry {
                method: first.to_string(),
                path: second.to_string(),
                line: line_idx as u32,
            });
        }
    }

    ParsedDocument { entries }
}

#[cfg(test)]
pub fn collect_diagnostics(text: &str) -> Vec<Diagnostic> {
    collect_diagnostics_with_external(text, &BTreeSet::new())
}

pub fn collect_diagnostics_with_external(
    text: &str,
    external_variables: &BTreeSet<String>,
) -> Vec<Diagnostic> {
    let mut diagnostics = Vec::new();
    let mut seen_sections_in_request = BTreeSet::new();
    if let Err(error) = hurl_core::parser::parse_hurl_file(text) {
        let line = error.pos.line.saturating_sub(1) as u32;
        let character = error.pos.column.saturating_sub(1) as u32;
        diagnostics.push(Diagnostic {
            range: Range::new(
                Position::new(line, character),
                Position::new(line, character.saturating_add(1)),
            ),
            severity: Some(DiagnosticSeverity::ERROR),
            source: Some("hurl_core".into()),
            message: error.description(),
            ..Default::default()
        });
    }

    for (line_idx, raw_line) in text.lines().enumerate() {
        let trimmed = raw_line.trim();

        let mut known_variables = visible_variables_before_line(text, line_idx);
        known_variables.extend(external_variables.iter().cloned());

        if let Some(section_name) = section_name_from_line(trimmed) {
            let section = section_label(section_name);
            let section_key = canonical_section_name(section_name);
            if !is_known_section(section_name) {
                diagnostics.push(Diagnostic {
                    range: Range::new(
                        Position::new(line_idx as u32, 0),
                        Position::new(line_idx as u32, raw_line.len() as u32),
                    ),
                    severity: Some(DiagnosticSeverity::ERROR),
                    source: Some("hurl-lsp".into()),
                    message: format!("Unknown section `{section}`"),
                    ..Default::default()
                });
            }
            if !seen_sections_in_request.insert(section_key.to_string()) {
                diagnostics.push(Diagnostic {
                    range: Range::new(
                        Position::new(line_idx as u32, 0),
                        Position::new(line_idx as u32, raw_line.len() as u32),
                    ),
                    severity: Some(DiagnosticSeverity::WARNING),
                    source: Some("hurl-lsp".into()),
                    message: format!("Duplicate section `{section}`"),
                    ..Default::default()
                });
            }
        }

        if is_probable_method_line(trimmed) {
            let method = trimmed.split_whitespace().next().unwrap_or_default();
            if !is_http_method(method) {
                diagnostics.push(Diagnostic {
                    range: Range::new(
                        Position::new(line_idx as u32, 0),
                        Position::new(line_idx as u32, method.len() as u32),
                    ),
                    severity: Some(DiagnosticSeverity::ERROR),
                    source: Some("hurl-lsp".into()),
                    message: format!("Unknown HTTP method `{method}`"),
                    ..Default::default()
                });
            } else {
                seen_sections_in_request.clear();
            }
        }

        if let Some(status) = trimmed
            .strip_prefix("HTTP ")
            .and_then(|rest| rest.split_whitespace().next())
        {
            if !is_valid_status(status) {
                diagnostics.push(Diagnostic {
                    range: Range::new(
                        Position::new(line_idx as u32, 0),
                        Position::new(line_idx as u32, raw_line.len() as u32),
                    ),
                    severity: Some(DiagnosticSeverity::ERROR),
                    source: Some("hurl-lsp".into()),
                    message: format!("Invalid HTTP status code `{status}`"),
                    ..Default::default()
                });
            }
        }

        if trimmed.starts_with('#') {
            continue;
        }

        for (start, end, variable) in variable_placeholders(raw_line) {
            if !is_identifier(variable) || known_variables.contains(variable) {
                continue;
            }
            diagnostics.push(Diagnostic {
                range: Range::new(
                    Position::new(line_idx as u32, start as u32),
                    Position::new(line_idx as u32, end as u32),
                ),
                severity: Some(DiagnosticSeverity::WARNING),
                source: Some("hurl-lsp".into()),
                message: format!("Undefined variable `{{{{{variable}}}}}`"),
                ..Default::default()
            });
        }
    }

    diagnostics.sort_by_key(|diagnostic| {
        (
            diagnostic.range.start.line,
            diagnostic.range.start.character,
            diagnostic.message.clone(),
        )
    });
    diagnostics.dedup_by(|left, right| {
        left.range == right.range
            && left.message == right.message
            && left.severity == right.severity
    });

    diagnostics
}

fn is_valid_status(status: &str) -> bool {
    status == "*" || (status.len() == 3 && status.chars().all(|ch| ch.is_ascii_digit()))
}

fn is_probable_method_line(line: &str) -> bool {
    if line.is_empty()
        || line.starts_with('#')
        || section_name_from_line(line).is_some()
        || line.starts_with('{')
        || line.starts_with("HTTP ")
    {
        return false;
    }

    let token = line.split_whitespace().next().unwrap_or_default();
    token.chars().all(|ch| ch.is_ascii_uppercase())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_entries() {
        let parsed = parse_document("GET https://example.com\nHTTP 200\n\nPOST /users\nHTTP 201\n");
        assert_eq!(parsed.entries.len(), 2);
        assert_eq!(parsed.entries[0].method, "GET");
        assert_eq!(parsed.entries[1].path, "/users");
    }

    #[test]
    fn flags_unknown_section() {
        let diagnostics = collect_diagnostics("[Headerz]\nfoo: bar\n");
        assert!(diagnostics
            .iter()
            .any(|diagnostic| diagnostic.message.contains("Unknown section")));
    }

    #[test]
    fn warns_for_undefined_variables() {
        let diagnostics = collect_diagnostics("GET https://example.com/{{missing}}\nHTTP 200\n");
        assert!(diagnostics
            .iter()
            .any(|diagnostic| diagnostic.message.contains("Undefined variable")));
    }

    #[test]
    fn does_not_warn_for_captured_variables() {
        let diagnostics = collect_diagnostics(
            "GET https://example.com/users\nHTTP 200\n[Captures]\nuser_id: jsonpath \"$.id\"\n\nGET https://example.com/users/{{user_id}}\nHTTP 200\n",
        );
        assert!(!diagnostics
            .iter()
            .any(|diagnostic| diagnostic.message.contains("Undefined variable")));
    }

    #[test]
    fn warns_for_duplicate_section() {
        let diagnostics =
            collect_diagnostics("GET /users\nHTTP 200\n[Headers]\nx-a: b\n[Headers]\nx-c: d\n");
        assert!(diagnostics
            .iter()
            .any(|diagnostic| diagnostic.message.contains("Duplicate section")));
    }

    #[test]
    fn errors_for_invalid_http_status_format() {
        let diagnostics = collect_diagnostics("GET /users\nHTTP abc\n");
        assert!(diagnostics
            .iter()
            .any(|diagnostic| diagnostic.message.contains("Invalid HTTP status code")));
    }

    #[test]
    fn allows_http_star_status() {
        let diagnostics = collect_diagnostics("GET /users\nHTTP *\n");
        assert!(!diagnostics
            .iter()
            .any(|diagnostic| diagnostic.message.contains("Invalid HTTP status code")));
    }

    #[test]
    fn accepts_short_section_names() {
        let diagnostics = collect_diagnostics("GET /users\nHTTP 200\n[Query]\nid: 1\n");
        assert!(!diagnostics
            .iter()
            .any(|diagnostic| diagnostic.message.contains("Unknown section")));
    }

    #[test]
    fn ignores_json_array_as_section() {
        let diagnostics = collect_diagnostics("GET /users\nHTTP 200\n[\n  1,\n  2\n]\n");
        assert!(!diagnostics
            .iter()
            .any(|diagnostic| diagnostic.message.contains("Unknown section")));
    }

    #[test]
    fn does_not_treat_later_capture_as_visible() {
        let diagnostics = collect_diagnostics(
            "GET /users/{{user_id}}\nHTTP 200\n\nGET /users\nHTTP 200\n[Captures]\nuser_id: jsonpath \"$.id\"\n",
        );
        assert!(diagnostics.iter().any(|diagnostic| diagnostic
            .message
            .contains("Undefined variable `{{user_id}}`")));
    }

    #[test]
    fn recognizes_builtin_template_variables() {
        let diagnostics = collect_diagnostics("GET /users/{{newUuid}}\nHTTP 200\n");
        assert!(!diagnostics
            .iter()
            .any(|diagnostic| diagnostic.message.contains("Undefined variable")));
    }

    #[test]
    fn warns_for_duplicate_section_aliases() {
        let diagnostics =
            collect_diagnostics("GET /users\nHTTP 200\n[Query]\na: 1\n[QueryStringParams]\nb: 2\n");
        assert!(diagnostics
            .iter()
            .any(|diagnostic| diagnostic.message.contains("Duplicate section")));
    }
}