use lsp_types::{DocumentLink, DocumentLinkParams, Position, Range, Uri};
use rustledger_core::Directive;
use rustledger_parser::ParseResult;
use std::path::Path;
use super::utils::byte_offset_to_position;
pub fn handle_document_links(
params: &DocumentLinkParams,
source: &str,
parse_result: &ParseResult,
) -> Option<Vec<DocumentLink>> {
let mut links = Vec::new();
let base_uri = ¶ms.text_document.uri;
let base_dir = get_base_directory(base_uri);
for spanned in &parse_result.directives {
if let Directive::Document(doc) = &spanned.value {
let path_str = doc.path.to_string();
if let Some(link) =
create_document_link(source, spanned.span.start, &path_str, &base_dir)
{
links.push(link);
}
}
}
for (line_num, line) in source.lines().enumerate() {
let trimmed = line.trim();
if trimmed.starts_with("include")
&& let Some(link) = parse_include_line(line, line_num as u32, &base_dir)
{
links.push(link);
}
}
if links.is_empty() { None } else { Some(links) }
}
pub fn handle_document_link_resolve(link: DocumentLink) -> DocumentLink {
let mut resolved = link.clone();
if let Some(data) = &link.data {
let path = data.get("path").and_then(|v| v.as_str()).unwrap_or("");
let base_dir = data
.get("base_dir")
.and_then(|v| v.as_str())
.map(String::from);
let kind = data.get("kind").and_then(|v| v.as_str()).unwrap_or("file");
let resolved_path = resolve_full_path(path, &base_dir);
let exists = resolved_path
.as_ref()
.map(|p| Path::new(p).exists())
.unwrap_or(false);
if let Some(ref full_path) = resolved_path
&& let Ok(uri) = format!("file://{}", full_path).parse::<Uri>()
{
resolved.target = Some(uri);
}
let tooltip = if exists {
match kind {
"include" => format!("Open included file: {}", path),
"document" => format!("Open document: {}", path),
_ => format!("Open {}", path),
}
} else {
format!("âš File not found: {}", path)
};
resolved.tooltip = Some(tooltip);
}
resolved
}
fn resolve_full_path(path: &str, base_dir: &Option<String>) -> Option<String> {
if Path::new(path).is_absolute() {
Some(path.to_string())
} else if let Some(base) = base_dir {
let base_path = Path::new(base);
Some(base_path.join(path).to_string_lossy().to_string())
} else {
None
}
}
fn get_base_directory(uri: &Uri) -> Option<String> {
let uri_str = uri.as_str();
if let Some(path_str) = uri_str.strip_prefix("file://") {
let path = Path::new(path_str);
path.parent().map(|p| p.to_string_lossy().to_string())
} else {
None
}
}
fn create_document_link(
source: &str,
directive_start: usize,
path: &str,
base_dir: &Option<String>,
) -> Option<DocumentLink> {
let (start_line, _) = byte_offset_to_position(source, directive_start);
let lines: Vec<&str> = source.lines().collect();
let line = lines.get(start_line as usize)?;
let quote_start = line.find('"')?;
let after_quote = &line[quote_start + 1..];
let quote_end = after_quote.find('"')?;
let path_in_line = &after_quote[..quote_end];
if path_in_line != path {
return None;
}
let start_col = (quote_start + 1) as u32;
let end_col = start_col + path.len() as u32;
let data = serde_json::json!({
"path": path,
"base_dir": base_dir,
"kind": "document",
});
Some(DocumentLink {
range: Range {
start: Position::new(start_line, start_col),
end: Position::new(start_line, end_col),
},
target: None, tooltip: None, data: Some(data),
})
}
fn parse_include_line(
line: &str,
line_num: u32,
base_dir: &Option<String>,
) -> Option<DocumentLink> {
let trimmed = line.trim();
if !trimmed.starts_with("include") {
return None;
}
let quote_start = line.find('"')?;
let after_quote = &line[quote_start + 1..];
let quote_end = after_quote.find('"')?;
let path = &after_quote[..quote_end];
let start_col = (quote_start + 1) as u32;
let end_col = start_col + path.len() as u32;
let data = serde_json::json!({
"path": path,
"base_dir": base_dir,
"kind": "include",
});
Some(DocumentLink {
range: Range {
start: Position::new(line_num, start_col),
end: Position::new(line_num, end_col),
},
target: None, tooltip: None, data: Some(data),
})
}
#[cfg(test)]
fn resolve_path_to_uri(path: &str, base_dir: &Option<String>) -> Option<Uri> {
let resolved = resolve_full_path(path, base_dir)?;
format!("file://{}", resolved).parse().ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_include_line() {
let line = r#"include "accounts.beancount""#;
let base_dir = Some("/home/user/ledger".to_string());
let link = parse_include_line(line, 0, &base_dir);
assert!(link.is_some());
let link = link.unwrap();
assert_eq!(link.range.start.character, 9); assert_eq!(link.range.end.character, 27);
assert!(link.target.is_none());
assert!(link.data.is_some());
}
#[test]
fn test_resolve_path_to_uri() {
let base_dir = Some("/home/user/ledger".to_string());
let uri = resolve_path_to_uri("accounts.beancount", &base_dir);
assert!(uri.is_some());
assert!(uri.unwrap().as_str().contains("accounts.beancount"));
}
#[test]
fn test_document_link_resolve() {
let link = DocumentLink {
range: Range {
start: Position::new(0, 9),
end: Position::new(0, 27),
},
target: None,
tooltip: None,
data: Some(serde_json::json!({
"path": "accounts.beancount",
"base_dir": "/home/user/ledger",
"kind": "include",
})),
};
let resolved = handle_document_link_resolve(link);
assert!(resolved.target.is_some());
let target = resolved.target.unwrap();
assert!(target.as_str().contains("accounts.beancount"));
assert!(resolved.tooltip.is_some());
let tooltip = resolved.tooltip.unwrap();
assert!(tooltip.contains("not found") || tooltip.contains("Open"));
}
#[test]
fn test_resolve_full_path() {
let base_dir = Some("/home/user/ledger".to_string());
let resolved = resolve_full_path("accounts.beancount", &base_dir);
assert!(resolved.is_some());
assert_eq!(resolved.unwrap(), "/home/user/ledger/accounts.beancount");
let resolved = resolve_full_path("/absolute/path.beancount", &base_dir);
assert!(resolved.is_some());
assert_eq!(resolved.unwrap(), "/absolute/path.beancount");
let resolved = resolve_full_path("relative.beancount", &None);
assert!(resolved.is_none());
}
}