rototo 0.1.0-alpha.4

Control plane for runtime configuration of your application.
Documentation
use std::path::{Component, Path, PathBuf};

use serde_json::Value as JsonValue;

use crate::diagnostics::SourcePosition;
use crate::error::{Result, RototoError};

pub(super) async fn initialize_workspace_root(params: &JsonValue) -> Result<Option<PathBuf>> {
    if let Some(root_uri) = params.get("rootUri").and_then(JsonValue::as_str) {
        return canonicalize_workspace_root(path_from_file_uri(root_uri)?).await;
    }
    if let Some(root_path) = params.get("rootPath").and_then(JsonValue::as_str) {
        return canonicalize_workspace_root(PathBuf::from(root_path)).await;
    }
    if let Some(workspace_folder_uri) = params
        .get("workspaceFolders")
        .and_then(JsonValue::as_array)
        .and_then(|folders| folders.first())
        .and_then(|folder| folder.get("uri"))
        .and_then(JsonValue::as_str)
    {
        return canonicalize_workspace_root(path_from_file_uri(workspace_folder_uri)?).await;
    }
    canonicalize_workspace_root(
        std::env::current_dir()
            .map_err(|err| RototoError::new(format!("failed to read current directory: {err}")))?,
    )
    .await
}

async fn canonicalize_workspace_root(path: PathBuf) -> Result<Option<PathBuf>> {
    let root = tokio::fs::canonicalize(&path).await.map_err(|err| {
        RototoError::new(format!(
            "failed to canonicalize LSP workspace root {}: {err}",
            path.display()
        ))
    })?;
    Ok(Some(root))
}

pub(super) fn json_i32(value: Option<&JsonValue>) -> Option<i32> {
    value
        .and_then(JsonValue::as_i64)
        .and_then(|value| i32::try_from(value).ok())
}

pub(super) fn source_position_from_json(value: &JsonValue) -> Result<SourcePosition> {
    let line = value
        .get("line")
        .and_then(JsonValue::as_u64)
        .and_then(|line| usize::try_from(line).ok())
        .ok_or_else(|| RototoError::new("position missing line"))?;
    let character = value
        .get("character")
        .and_then(JsonValue::as_u64)
        .and_then(|character| usize::try_from(character).ok())
        .ok_or_else(|| RototoError::new("position missing character"))?;
    Ok(SourcePosition { line, character })
}

pub(super) fn path_from_file_uri(uri: &str) -> Result<PathBuf> {
    let path = uri
        .strip_prefix("file://")
        .ok_or_else(|| RototoError::new(format!("unsupported LSP URI: {uri}")))?;
    percent_decode_path(path).map(PathBuf::from)
}

fn percent_decode_path(path: &str) -> Result<String> {
    let mut decoded = Vec::with_capacity(path.len());
    let bytes = path.as_bytes();
    let mut index = 0;
    while index < bytes.len() {
        if bytes[index] == b'%' {
            let high = bytes.get(index + 1).copied().and_then(hex_value);
            let low = bytes.get(index + 2).copied().and_then(hex_value);
            match (high, low) {
                (Some(high), Some(low)) => {
                    decoded.push((high << 4) | low);
                    index += 3;
                }
                _ => {
                    return Err(RototoError::new(format!(
                        "invalid percent-encoded LSP URI path: {path}"
                    )));
                }
            }
        } else {
            decoded.push(bytes[index]);
            index += 1;
        }
    }
    String::from_utf8(decoded)
        .map_err(|err| RototoError::new(format!("LSP URI path is not UTF-8: {err}")))
}

fn hex_value(byte: u8) -> Option<u8> {
    match byte {
        b'0'..=b'9' => Some(byte - b'0'),
        b'a'..=b'f' => Some(byte - b'a' + 10),
        b'A'..=b'F' => Some(byte - b'A' + 10),
        _ => None,
    }
}

pub(super) fn workspace_relative_path(root: &Path, path: &Path) -> Result<String> {
    let canonical_path = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
    let relative = canonical_path.strip_prefix(root).map_err(|_| {
        RototoError::new(format!(
            "LSP document is outside workspace: {}",
            path.display()
        ))
    })?;
    let workspace_path = relative
        .components()
        .filter_map(|component| match component {
            Component::Normal(segment) => Some(segment.to_string_lossy().into_owned()),
            _ => None,
        })
        .collect::<Vec<_>>()
        .join("/");
    if workspace_path.is_empty() {
        return Err(RototoError::new("LSP document path is workspace root"));
    }
    Ok(workspace_path)
}

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

    #[test]
    fn file_uri_paths_percent_decode_special_and_multibyte_bytes() {
        assert_eq!(
            path_from_file_uri("file:///tmp/rototo%20%23%C3%A9%25.toml")
                .unwrap()
                .to_string_lossy(),
            "/tmp/rototo #é%.toml"
        );
    }

    #[test]
    fn file_uri_paths_reject_bad_percent_encoding_and_utf8() {
        assert!(path_from_file_uri("file:///tmp/%").is_err());
        assert!(path_from_file_uri("file:///tmp/%GG").is_err());
        assert!(path_from_file_uri("file:///tmp/%FF").is_err());
        assert!(path_from_file_uri("https://example.test/workspace").is_err());
    }
}