magi-code 0.63.0

Repository-aware CLI coding agent for terminal work
Documentation
use crate::lsp::{DocumentUri, LspPosition, LspRange};
use anyhow::Context;
use serde::Deserialize;
use std::path::{Path, PathBuf};

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ReferenceLocation {
    pub(crate) path: PathBuf,
    pub(crate) range: LspRange,
}

#[derive(Debug, Clone, Deserialize)]
pub(crate) struct LspLocation {
    uri: DocumentUri,
    range: LspRange,
}

impl LspLocation {
    pub(crate) fn into_reference_location(self) -> anyhow::Result<ReferenceLocation> {
        Ok(ReferenceLocation {
            path: self.uri.to_file_path()?,
            range: self.range,
        })
    }
}

pub(crate) fn parse_reference_locations(
    value: serde_json::Value,
) -> anyhow::Result<Vec<ReferenceLocation>> {
    if value.is_null() {
        return Ok(Vec::new());
    }
    let locations: Vec<LspLocation> =
        serde_json::from_value(value).context("parsing LSP locations")?;
    locations
        .into_iter()
        .map(LspLocation::into_reference_location)
        .collect()
}

pub(crate) fn format_references(
    locations: &[ReferenceLocation],
    limit: usize,
    workspace_root: &Path,
) -> String {
    let mut lines = Vec::new();
    let shown = locations.len().min(limit);
    for location in locations.iter().take(shown) {
        let path = location
            .path
            .strip_prefix(workspace_root)
            .unwrap_or(&location.path);
        lines.push(format!(
            "{}:{}:{}",
            path.display(),
            location.range.start.line + 1,
            location.range.start.character + 1
        ));
    }
    if locations.len() > shown {
        lines.push(format!(
            "... truncated {} references",
            locations.len() - shown
        ));
    }
    crate::output::redact_sensitive_text(&lines.join("\n"))
}

pub(crate) fn one_based_to_lsp(line: u32, column: u32, text: &str) -> anyhow::Result<LspPosition> {
    if line == 0 || column == 0 {
        anyhow::bail!("line and column must be 1-based positive integers");
    }
    let zero_based_line = line - 1;
    let Some(line_text) = text.lines().nth(zero_based_line as usize) else {
        anyhow::bail!("line {line} is outside file");
    };
    let utf16_character = utf16_units_for_one_based_column(line_text, column)?;
    Ok(LspPosition {
        line: zero_based_line,
        character: utf16_character,
    })
}

#[cfg(test)]
pub(crate) fn lsp_to_one_based(position: LspPosition, text: &str) -> anyhow::Result<(u32, u32)> {
    let line = position.line + 1;
    let Some(line_text) = text.lines().nth(position.line as usize) else {
        anyhow::bail!("line {line} is outside file");
    };
    let column = one_based_column_for_utf16_units(line_text, position.character)?;
    Ok((line, column))
}

fn utf16_units_for_one_based_column(line: &str, column: u32) -> anyhow::Result<u32> {
    let target_chars = (column - 1) as usize;
    if target_chars > line.chars().count() {
        anyhow::bail!("column {column} is outside line");
    }
    Ok(line
        .chars()
        .take(target_chars)
        .map(char::len_utf16)
        .sum::<usize>() as u32)
}

#[cfg(test)]
fn one_based_column_for_utf16_units(line: &str, character: u32) -> anyhow::Result<u32> {
    let mut utf16_units = 0u32;
    let mut column = 1u32;
    for ch in line.chars() {
        if utf16_units == character {
            return Ok(column);
        }
        utf16_units += ch.len_utf16() as u32;
        column += 1;
    }
    if utf16_units == character {
        Ok(column)
    } else {
        anyhow::bail!("UTF-16 character offset {character} is outside line")
    }
}

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

    #[test]
    fn references_response_maps_uris_to_paths() {
        let temp = tempfile::TempDir::new().unwrap();
        let path = temp.path().join("lib.rs");
        std::fs::write(&path, "fn x() {}\n").unwrap();
        let uri = DocumentUri::from_path(&path).unwrap();
        let locations = parse_reference_locations(json!([{
            "uri": uri.as_str(),
            "range": {"start": {"line": 2, "character": 3}, "end": {"line": 2, "character": 4}}
        }]))
        .unwrap();

        assert_eq!(locations.len(), 1);
        assert_eq!(locations[0].path, path.canonicalize().unwrap());
        assert_eq!(locations[0].range.start.character, 3);
    }

    #[test]
    fn format_references_uses_one_based_output_and_limit() {
        let root = PathBuf::from("/tmp/root");
        let locations = vec![
            ReferenceLocation {
                path: root.join("a.rs"),
                range: LspRange {
                    start: LspPosition {
                        line: 0,
                        character: 4,
                    },
                    end: LspPosition {
                        line: 0,
                        character: 5,
                    },
                },
            },
            ReferenceLocation {
                path: root.join("b.rs"),
                range: LspRange {
                    start: LspPosition {
                        line: 1,
                        character: 0,
                    },
                    end: LspPosition {
                        line: 1,
                        character: 1,
                    },
                },
            },
        ];

        assert_eq!(
            format_references(&locations, 1, &root),
            "a.rs:1:5\n... truncated 1 references"
        );
    }

    #[test]
    fn utf16_coordinate_conversion_handles_non_ascii() {
        let text = "a🦀b\nnext";

        let pos = one_based_to_lsp(1, 3, text).unwrap();
        assert_eq!(pos.line, 0);
        assert_eq!(pos.character, 3);
        assert_eq!(lsp_to_one_based(pos, text).unwrap(), (1, 3));
        assert!(one_based_to_lsp(0, 1, text).is_err());
        assert!(one_based_to_lsp(1, 99, text).is_err());
    }
}