hurl-lsp 0.1.11

Language Server Protocol implementation for Hurl
use std::{
    collections::BTreeMap,
    fs,
    path::{Path, PathBuf},
};
use tower_lsp::lsp_types::Url;

const VARIABLE_FILES: &[&str] = &[".hurl-vars", "vars.env", "hurl.env", ".env"];

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct VariableDef {
    pub name: String,
    pub value: String,
    pub uri: Url,
    pub line: u32,
    pub start: u32,
    pub end: u32,
}

pub fn load_workspace_variables_with_roots(
    document_uri: &Url,
    workspace_roots: &[PathBuf],
) -> Vec<VariableDef> {
    let Some(base_dir) = base_dir_from_uri(document_uri) else {
        return Vec::new();
    };
    let mut dirs = bounded_ancestor_dirs(base_dir, workspace_roots);
    dirs.reverse();

    let mut vars = BTreeMap::<String, VariableDef>::new();
    for dir in dirs {
        for file_name in VARIABLE_FILES {
            let file_path = dir.join(file_name);
            if !file_path.exists() || !file_path.is_file() {
                continue;
            }
            for var in parse_variable_file(&file_path) {
                vars.insert(var.name.clone(), var);
            }
        }
    }

    vars.into_values().collect()
}

pub fn pick_variable_file_with_roots(
    document_uri: &Url,
    workspace_roots: &[PathBuf],
) -> Option<PathBuf> {
    let base_dir = base_dir_from_uri(document_uri)?;
    let dirs = bounded_ancestor_dirs(base_dir, workspace_roots);
    for dir in dirs {
        for file_name in VARIABLE_FILES {
            let file_path = dir.join(file_name);
            if file_path.exists() && file_path.is_file() {
                return Some(file_path);
            }
        }
    }
    None
}

fn file_path_from_uri(uri: &Url) -> Option<PathBuf> {
    if uri.scheme() != "file" {
        return None;
    }
    uri.to_file_path().ok()
}

fn base_dir_from_uri(uri: &Url) -> Option<PathBuf> {
    let path = file_path_from_uri(uri)?;
    path.parent().map(Path::to_path_buf)
}

fn bounded_ancestor_dirs(base_dir: PathBuf, workspace_roots: &[PathBuf]) -> Vec<PathBuf> {
    let normalized_roots: Vec<PathBuf> = workspace_roots
        .iter()
        .map(|root| root.canonicalize().unwrap_or_else(|_| root.clone()))
        .collect();
    let normalized_base = base_dir.canonicalize().unwrap_or_else(|_| base_dir.clone());

    let selected_root = normalized_roots
        .iter()
        .filter(|root| normalized_base.starts_with(root))
        .max_by_key(|root| root.components().count())
        .cloned();

    let mut dirs = Vec::new();
    let mut current = Some(normalized_base);
    while let Some(dir) = current {
        if let Some(root) = &selected_root {
            if !dir.starts_with(root) {
                break;
            }
        }
        dirs.push(dir.clone());
        if let Some(root) = &selected_root {
            if dir == *root {
                break;
            }
        } else {
            break;
        }
        current = dir.parent().map(Path::to_path_buf);
    }
    dirs
}

fn parse_variable_file(path: &Path) -> Vec<VariableDef> {
    let Ok(content) = fs::read_to_string(path) else {
        return Vec::new();
    };
    let Ok(uri) = Url::from_file_path(path) else {
        return Vec::new();
    };

    content
        .lines()
        .enumerate()
        .filter_map(|(idx, line)| {
            parse_variable_line(line)
                .map(|(name, value, start, end)| (idx, name, value, start, end))
        })
        .map(|(idx, name, value, start, end)| VariableDef {
            name,
            value,
            uri: uri.clone(),
            line: idx as u32,
            start: start as u32,
            end: end as u32,
        })
        .collect()
}

fn parse_variable_line(line: &str) -> Option<(String, String, usize, usize)> {
    let trimmed = line.trim();
    if trimmed.is_empty() || trimmed.starts_with('#') {
        return None;
    }
    let (left, right) = line.split_once('=')?;
    let name = left.trim();
    if !is_identifier(name) {
        return None;
    }
    let value = right.trim().to_string();
    let start = line.find(name)?;
    Some((name.to_string(), value, start, start + name.len()))
}

fn is_identifier(name: &str) -> bool {
    !name.is_empty()
        && name
            .chars()
            .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-')
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::time::{SystemTime, UNIX_EPOCH};

    #[test]
    fn loads_variables_from_detected_files() {
        let base = tmp_dir("hurl-lsp-vars");
        fs::create_dir_all(&base).expect("mkdir");
        fs::write(base.join(".env"), "host=example.com\n# note\nport=443\n").expect("write env");
        let nested = base.join("api");
        fs::create_dir_all(&nested).expect("mkdir nested");
        let uri = Url::from_file_path(nested.join("test.hurl")).expect("uri");

        let vars = load_workspace_variables_with_roots(&uri, std::slice::from_ref(&base));
        assert!(vars
            .iter()
            .any(|var| var.name == "host" && var.value == "example.com"));
        assert!(vars
            .iter()
            .any(|var| var.name == "port" && var.value == "443"));

        let _ = fs::remove_dir_all(base);
    }

    #[test]
    fn overrides_with_nearest_file() {
        let base = tmp_dir("hurl-lsp-vars-override");
        let nested = base.join("project");
        fs::create_dir_all(&nested).expect("mkdir");
        fs::write(base.join(".env"), "host=global.example.com\n").expect("write root");
        fs::write(nested.join(".env"), "host=local.example.com\n").expect("write nested");
        let uri = Url::from_file_path(nested.join("case.hurl")).expect("uri");

        let vars = load_workspace_variables_with_roots(&uri, std::slice::from_ref(&base));
        let host = vars.iter().find(|var| var.name == "host").expect("host");
        assert_eq!(host.value, "local.example.com");

        let _ = fs::remove_dir_all(base);
    }

    #[test]
    fn picks_nearest_variable_file() {
        let base = tmp_dir("hurl-lsp-vars-pick");
        let nested = base.join("project").join("api");
        fs::create_dir_all(&nested).expect("mkdir");
        fs::write(base.join(".env"), "host=root.example.com\n").expect("write root");
        fs::write(
            base.join("project").join("vars.env"),
            "host=project.example.com\n",
        )
        .expect("write project");
        let uri = Url::from_file_path(nested.join("case.hurl")).expect("uri");

        let picked =
            pick_variable_file_with_roots(&uri, std::slice::from_ref(&base)).expect("picked");
        assert_eq!(
            picked.file_name().and_then(|n| n.to_str()),
            Some("vars.env")
        );

        let _ = fs::remove_dir_all(base);
    }

    #[test]
    fn does_not_read_variables_outside_workspace_root() {
        let base = tmp_dir("hurl-lsp-vars-bounded");
        let workspace = base.join("workspace");
        let nested = workspace.join("api");
        fs::create_dir_all(&nested).expect("mkdir nested");
        fs::write(base.join(".env"), "outer_only=1\n").expect("write outer");
        fs::write(workspace.join(".env"), "inner_only=1\n").expect("write inner");
        let uri = Url::from_file_path(nested.join("test.hurl")).expect("uri");

        let vars = load_workspace_variables_with_roots(&uri, std::slice::from_ref(&workspace));
        assert!(vars.iter().any(|var| var.name == "inner_only"));
        assert!(!vars.iter().any(|var| var.name == "outer_only"));

        let _ = fs::remove_dir_all(base);
    }

    #[test]
    fn keeps_full_value_when_contains_equal_sign() {
        let value = parse_variable_line("token=abc=def").expect("parsed").1;
        assert_eq!(value, "abc=def");
    }

    fn tmp_dir(prefix: &str) -> PathBuf {
        let now = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .expect("clock")
            .as_nanos();
        std::env::temp_dir().join(format!("{prefix}-{now}"))
    }
}