hurl-lsp 0.1.11

Language Server Protocol implementation for Hurl
use crate::{
    diagnostics::parse_document,
    execution::RunSummary,
    metadata::{infer_entry_dependencies, HurlMetaParser},
};
use std::collections::{BTreeMap, BTreeSet};
use tower_lsp::lsp_types::{CodeLens, Command, Position, Range, Url};

pub const RUN_ENTRY_COMMAND: &str = "hurl.runEntry";
pub const RUN_ENTRY_WITH_VARS_COMMAND: &str = "hurl.runEntryWithVars";
pub const RUN_CHAIN_COMMAND: &str = "hurl.runChain";
pub const RUN_FILE_COMMAND: &str = "hurl.runFile";
pub const COPY_AS_CURL_COMMAND: &str = "hurl.copyAsCurl";
pub const CLEAR_RUN_DIAGNOSTICS_COMMAND: &str = "hurl.clearRunDiagnostics";
pub const NOOP_COMMAND: &str = "hurl.noop";

#[cfg(test)]
pub fn code_lenses(uri: &Url, text: &str) -> Vec<CodeLens> {
    code_lenses_with_context(uri, text, &BTreeMap::new())
}

pub fn code_lenses_with_context(
    uri: &Url,
    text: &str,
    run_summaries: &BTreeMap<u32, RunSummary>,
) -> Vec<CodeLens> {
    let parsed = parse_document(text);
    let meta = HurlMetaParser::parse(text);
    let deps = infer_entry_dependencies(text, &meta);
    let mut deps_in = BTreeMap::<u32, BTreeSet<String>>::new();
    let mut deps_out = BTreeMap::<u32, BTreeSet<String>>::new();
    for dep in deps {
        let in_edge = if dep.variables.is_empty() {
            dep.from_step.clone()
        } else {
            format!("{}{}", dep.variables.join(", "), dep.from_step)
        };
        let out_edge = if dep.variables.is_empty() {
            dep.to_step.clone()
        } else {
            format!("{}{}", dep.variables.join(", "), dep.to_step)
        };
        deps_in.entry(dep.to_line).or_default().insert(in_edge);
        deps_out.entry(dep.from_line).or_default().insert(out_edge);
    }
    let lines: Vec<&str> = text.lines().collect();

    parsed
        .entries
        .iter()
        .flat_map(|entry| {
            let start = Position::new(entry.line, 0);
            let range = Range::new(start, start);
            let (headers, asserts, captures) =
                count_sections_after_entry(text, entry.line as usize);
            let status = run_summaries.get(&entry.line).map(format_run_status_suffix);
            let title = if let Some(status) = status {
                format!(
                    "📋 {} {}{} headers │ {} asserts │ {} captures │ {}",
                    entry.method, entry.path, headers, asserts, captures, status
                )
            } else {
                format!(
                    "📋 {} {}{} headers │ {} asserts │ {} captures",
                    entry.method, entry.path, headers, asserts, captures
                )
            };
            let summary = CodeLens {
                range,
                command: Some(Command {
                    title,
                    command: NOOP_COMMAND.to_string(),
                    arguments: None,
                }),
                data: None,
            };
            let dep_title =
                dependency_title(entry.line, &deps_in, &deps_out).map(|title| CodeLens {
                    range,
                    command: Some(Command {
                        title,
                        command: NOOP_COMMAND.to_string(),
                        arguments: None,
                    }),
                    data: None,
                });
            let run = CodeLens {
                range,
                command: Some(Command {
                    title: "▶ Run".to_string(),
                    command: RUN_ENTRY_COMMAND.to_string(),
                    arguments: Some(vec![
                        serde_json::Value::String(uri.to_string()),
                        serde_json::Value::Number((entry.line as u64).into()),
                    ]),
                }),
                data: None,
            };
            let run_with_vars = CodeLens {
                range,
                command: Some(Command {
                    title: "⚡ Run with vars".to_string(),
                    command: RUN_ENTRY_WITH_VARS_COMMAND.to_string(),
                    arguments: Some(vec![
                        serde_json::Value::String(uri.to_string()),
                        serde_json::Value::Number((entry.line as u64).into()),
                    ]),
                }),
                data: None,
            };
            let run_chain = CodeLens {
                range,
                command: Some(Command {
                    title: "⛓ Run chain".to_string(),
                    command: RUN_CHAIN_COMMAND.to_string(),
                    arguments: Some(vec![
                        serde_json::Value::String(uri.to_string()),
                        serde_json::Value::Number((entry.line as u64).into()),
                    ]),
                }),
                data: None,
            };
            let run_file = CodeLens {
                range,
                command: Some(Command {
                    title: "📄 Run file".to_string(),
                    command: RUN_FILE_COMMAND.to_string(),
                    arguments: Some(vec![
                        serde_json::Value::String(uri.to_string()),
                        serde_json::Value::Number((entry.line as u64).into()),
                    ]),
                }),
                data: None,
            };
            let copy_as_curl = CodeLens {
                range,
                command: Some(Command {
                    title: "📋 Copy as curl".to_string(),
                    command: COPY_AS_CURL_COMMAND.to_string(),
                    arguments: Some(vec![
                        serde_json::Value::String(uri.to_string()),
                        serde_json::Value::Number((entry.line as u64).into()),
                    ]),
                }),
                data: None,
            };
            let mut items = vec![summary];
            if let Some(dep) = dep_title {
                items.push(dep);
            }
            items.push(run);
            items.push(run_with_vars);
            items.push(run_chain);
            items.push(run_file);
            items.push(copy_as_curl);
            items
        })
        .collect::<Vec<_>>()
        .into_iter()
        .filter(|lens| lens.range.start.line as usize <= lines.len())
        .collect()
}

fn count_sections_after_entry(text: &str, entry_line: usize) -> (usize, usize, usize) {
    let mut headers = 0;
    let mut asserts = 0;
    let mut captures = 0;
    let mut in_current_entry = false;

    for (idx, line) in text.lines().enumerate() {
        let trimmed = line.trim();
        if idx == entry_line {
            in_current_entry = true;
            continue;
        }
        if !in_current_entry {
            continue;
        }
        if crate::syntax::method_from_line(trimmed).is_some() {
            break;
        }
        match trimmed {
            "[Headers]" => headers += 1,
            "[Asserts]" => asserts += 1,
            "[Captures]" => captures += 1,
            _ => {}
        }
    }

    (headers, asserts, captures)
}

pub fn build_curl_for_entry(text: &str, entry_line: usize) -> Option<String> {
    let parsed = parse_document(text);
    let entry = parsed
        .entries
        .iter()
        .find(|item| item.line as usize == entry_line)?;

    let mut headers = Vec::new();
    let mut in_headers = false;
    for (idx, raw) in text.lines().enumerate() {
        if idx <= entry_line {
            continue;
        }
        let line = raw.trim();
        if crate::syntax::method_from_line(line).is_some() {
            break;
        }
        if let Some(section) = crate::syntax::section_name_from_line(line) {
            in_headers = section == "Headers";
            continue;
        }
        if !in_headers || line.is_empty() || line.starts_with('#') {
            continue;
        }
        if let Some((k, v)) = line.split_once(':') {
            headers.push(format!("{}: {}", k.trim(), v.trim()));
        }
    }

    let mut command = format!(
        "curl -X {} '{}'",
        entry.method,
        shell_single_quote(&entry.path)
    );
    for header in headers {
        command.push_str(&format!(" -H '{}'", shell_single_quote(&header)));
    }
    Some(command)
}

pub fn extract_entry_text(text: &str, entry_line: usize) -> Option<String> {
    let parsed = parse_document(text);
    let mut entry_lines: Vec<usize> = parsed
        .entries
        .iter()
        .map(|entry| entry.line as usize)
        .collect();
    entry_lines.sort_unstable();
    let start = entry_lines
        .iter()
        .copied()
        .find(|line| *line == entry_line)?;
    let end = entry_lines
        .iter()
        .copied()
        .find(|line| *line > start)
        .unwrap_or_else(|| text.lines().count());
    let lines: Vec<&str> = text.lines().collect();
    Some(lines[start..end].join("\n"))
}

fn shell_single_quote(value: &str) -> String {
    value.replace('\'', "'\"'\"'")
}

fn format_run_status_suffix(summary: &RunSummary) -> String {
    let duration = summary
        .duration_ms
        .map(|value| format!("{value}ms"))
        .unwrap_or_else(|| "unknown".to_string());
    if summary.success {
        format!("✅ 上次执行通过 ({duration})")
    } else if summary.failed_asserts > 0 {
        format!(
            "❌ 上次执行失败 ({} assert failed · {duration})",
            summary.failed_asserts
        )
    } else {
        format!("❌ 上次执行失败 ({duration})")
    }
}

fn dependency_title(
    line: u32,
    deps_in: &BTreeMap<u32, BTreeSet<String>>,
    deps_out: &BTreeMap<u32, BTreeSet<String>>,
) -> Option<String> {
    let incoming = deps_in.get(&line).map(|items| {
        format!(
            "📥 依赖: {}",
            items.iter().cloned().collect::<Vec<_>>().join(" | ")
        )
    });
    let outgoing = deps_out.get(&line).map(|items| {
        format!(
            "📤 输出: {}",
            items.iter().cloned().collect::<Vec<_>>().join(" | ")
        )
    });
    match (incoming, outgoing) {
        (Some(left), Some(right)) => Some(format!("{left}{right}")),
        (Some(left), None) => Some(left),
        (None, Some(right)) => Some(right),
        (None, None) => None,
    }
}

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

    #[test]
    fn generates_summary_and_run_lenses() {
        let uri = Url::parse("file:///tmp/test.hurl").expect("uri");
        let text = "GET /users\nHTTP 200\n[Headers]\na: b\n[Asserts]\nstatus == 200\n";
        let lenses = code_lenses(&uri, text);
        assert_eq!(lenses.len(), 6);
        assert!(lenses[0]
            .command
            .as_ref()
            .expect("summary")
            .title
            .contains("GET /users"));
        assert_eq!(
            lenses[1].command.as_ref().expect("run").command,
            RUN_ENTRY_COMMAND
        );
        assert_eq!(
            lenses[2].command.as_ref().expect("run vars").command,
            RUN_ENTRY_WITH_VARS_COMMAND
        );
        assert_eq!(
            lenses[3].command.as_ref().expect("run chain").command,
            RUN_CHAIN_COMMAND
        );
        assert_eq!(
            lenses[4].command.as_ref().expect("run file").command,
            RUN_FILE_COMMAND
        );
        assert_eq!(
            lenses[5].command.as_ref().expect("copy").command,
            COPY_AS_CURL_COMMAND
        );
    }

    #[test]
    fn chain_entry_lens_shows_depends_on_annotation() {
        let uri = Url::parse("file:///tmp/test.hurl").expect("uri");
        let text = "# step_id=step-setup-user\nPOST /users\nHTTP 201\n[Captures]\nuser_id: jsonpath \"$.id\"\n\n# step_id=step-test-get\nGET /users/{{user_id}}\nHTTP 200\n";
        let lenses = code_lenses(&uri, text);
        assert!(lenses.iter().any(|item| {
            item.command
                .as_ref()
                .map(|cmd| cmd.title.contains("📥 依赖: user_id ← step-setup-user"))
                .unwrap_or(false)
        }));
    }

    #[test]
    fn chain_entry_lens_shows_explicit_depends_on_without_variables() {
        let uri = Url::parse("file:///tmp/test.hurl").expect("uri");
        let text = "# step_id=step-a\nPOST /users\nHTTP 201\n\n# step_id=step-b\n# depends_on=step-a\nGET /health\nHTTP 200\n";
        let lenses = code_lenses(&uri, text);
        assert!(lenses.iter().any(|item| {
            item.command
                .as_ref()
                .map(|cmd| cmd.title.contains("📥 依赖: step-a"))
                .unwrap_or(false)
        }));
    }

    #[test]
    fn summary_lens_shows_last_run_status() {
        let uri = Url::parse("file:///tmp/test.hurl").expect("uri");
        let text = "GET /users\nHTTP 200\n";
        let mut summaries = BTreeMap::new();
        summaries.insert(
            0,
            RunSummary {
                success: false,
                failed_asserts: 1,
                duration_ms: Some(230),
            },
        );
        let lenses = code_lenses_with_context(&uri, text, &summaries);
        assert!(lenses[0]
            .command
            .as_ref()
            .expect("summary")
            .title
            .contains("❌ 上次执行失败 (1 assert failed · 230ms)"));
    }

    #[test]
    fn builds_curl_from_entry_line_and_headers() {
        let text = "POST https://example.com/users\nHTTP 201\n[Headers]\nContent-Type: application/json\nAuthorization: Bearer xxx\n";
        let curl = build_curl_for_entry(text, 0).expect("curl");
        assert!(curl.contains("curl -X POST"));
        assert!(curl.contains("-H 'Content-Type: application/json'"));
    }

    #[test]
    fn extracts_only_target_entry_text() {
        let text = "GET /users\nHTTP 200\n\nPOST /orders\nHTTP 201\n";
        let entry = extract_entry_text(text, 3).expect("entry");
        assert!(entry.starts_with("POST /orders"));
        assert!(!entry.contains("GET /users"));
    }
}