use std::collections::HashMap;
use lsp_types::{DocumentLink, DocumentLinkParams, Position, Range, Uri};
use crate::lsp::utils::parse_document;
pub fn handle_document_links(
documents: &HashMap<Uri, String>,
params: DocumentLinkParams,
) -> Option<Vec<DocumentLink>> {
let uri = ¶ms.text_document.uri;
let text = documents.get(uri)?;
let path = uri.path().as_str();
let mut links = Vec::new();
if let Some(ast) = parse_document(text, path) {
if let Some(ref within) = ast.within {
let within_str = within.to_string();
for (line_num, line) in text.lines().enumerate() {
if line.trim().starts_with("within ") {
if let Some(start) = line.find(&within_str) {
links.push(DocumentLink {
range: Range {
start: Position {
line: line_num as u32,
character: start as u32,
},
end: Position {
line: line_num as u32,
character: (start + within_str.len()) as u32,
},
},
target: None, tooltip: Some(format!("Go to package {}", within_str)),
data: None,
});
}
break;
}
}
}
for class in ast.class_list.values() {
collect_import_links(class, text, &mut links);
}
}
collect_annotation_links(text, &mut links);
collect_file_path_links(text, uri, &mut links);
Some(links)
}
fn collect_import_links(
class: &crate::ir::ast::ClassDefinition,
text: &str,
links: &mut Vec<DocumentLink>,
) {
for import in &class.imports {
let import_path = match import {
crate::ir::ast::Import::Qualified { path, .. } => path.to_string(),
crate::ir::ast::Import::Renamed { path, .. } => path.to_string(),
crate::ir::ast::Import::Unqualified { path, .. } => path.to_string(),
crate::ir::ast::Import::Selective { path, .. } => path.to_string(),
};
for (line_num, line) in text.lines().enumerate() {
if line.trim().starts_with("import ")
&& line.contains(&import_path)
&& let Some(start) = line.find(&import_path)
{
links.push(DocumentLink {
range: Range {
start: Position {
line: line_num as u32,
character: start as u32,
},
end: Position {
line: line_num as u32,
character: (start + import_path.len()) as u32,
},
},
target: None, tooltip: Some(format!("Go to {}", import_path)),
data: None,
});
}
}
}
for nested in class.classes.values() {
collect_import_links(nested, text, links);
}
}
fn collect_annotation_links(text: &str, links: &mut Vec<DocumentLink>) {
for (line_num, line) in text.lines().enumerate() {
if line.contains("Documentation") {
collect_uris_in_line(line, line_num, links);
}
if line.contains("fileName")
&& let Some(file_link) = extract_filename_link(line, line_num)
{
links.push(file_link);
}
if line.contains("uses(") {
collect_uses_links(line, line_num, links);
}
}
}
fn collect_uris_in_line(line: &str, line_num: usize, links: &mut Vec<DocumentLink>) {
let patterns = ["http://", "https://", "file://"];
for pattern in &patterns {
let mut search_pos = 0;
while let Some(start) = line[search_pos..].find(pattern) {
let abs_start = search_pos + start;
let url_start = abs_start;
let remaining = &line[url_start..];
let url_end = remaining
.find(|c: char| c.is_whitespace() || c == '"' || c == '\'' || c == '>' || c == '<')
.unwrap_or(remaining.len());
let url = &line[url_start..url_start + url_end];
if is_valid_url(url) {
links.push(DocumentLink {
range: Range {
start: Position {
line: line_num as u32,
character: url_start as u32,
},
end: Position {
line: line_num as u32,
character: (url_start + url_end) as u32,
},
},
target: url.parse().ok(),
tooltip: Some("Open URL".to_string()),
data: None,
});
}
search_pos = url_start + url_end;
if search_pos >= line.len() {
break;
}
}
}
}
fn extract_filename_link(line: &str, line_num: usize) -> Option<DocumentLink> {
let pattern = "fileName=\"";
let start = line.find(pattern)?;
let value_start = start + pattern.len();
let remaining = &line[value_start..];
let value_end = remaining.find('"')?;
let filename = &remaining[..value_end];
if !filename.is_empty() && !filename.starts_with("http") {
return Some(DocumentLink {
range: Range {
start: Position {
line: line_num as u32,
character: value_start as u32,
},
end: Position {
line: line_num as u32,
character: (value_start + value_end) as u32,
},
},
target: None, tooltip: Some(format!("Open {}", filename)),
data: None,
});
}
None
}
fn collect_uses_links(line: &str, line_num: usize, links: &mut Vec<DocumentLink>) {
if let Some(uses_start) = line.find("uses(") {
let remaining = &line[uses_start + 5..];
if let Some(paren_end) = remaining.find(')') {
let uses_content = &remaining[..paren_end];
for lib_ref in uses_content.split(',') {
let lib_ref = lib_ref.trim();
let lib_name = if let Some(paren) = lib_ref.find('(') {
lib_ref[..paren].trim()
} else {
lib_ref
};
if !lib_name.is_empty()
&& let Some(lib_pos) = line.find(lib_name)
{
links.push(DocumentLink {
range: Range {
start: Position {
line: line_num as u32,
character: lib_pos as u32,
},
end: Position {
line: line_num as u32,
character: (lib_pos + lib_name.len()) as u32,
},
},
target: None,
tooltip: Some(format!("Go to library {}", lib_name)),
data: None,
});
}
}
}
}
}
fn collect_file_path_links(text: &str, _base_uri: &Uri, links: &mut Vec<DocumentLink>) {
for (line_num, line) in text.lines().enumerate() {
let mut in_string = false;
let mut string_start = 0;
let chars: Vec<char> = line.chars().collect();
for (i, &c) in chars.iter().enumerate() {
if c == '"' && (i == 0 || chars[i - 1] != '\\') {
if in_string {
let content = &line[string_start + 1..i];
if looks_like_file_path(content) {
links.push(DocumentLink {
range: Range {
start: Position {
line: line_num as u32,
character: (string_start + 1) as u32,
},
end: Position {
line: line_num as u32,
character: i as u32,
},
},
target: None, tooltip: Some(format!("Open {}", content)),
data: None,
});
}
} else {
string_start = i;
}
in_string = !in_string;
}
}
}
}
fn looks_like_file_path(s: &str) -> bool {
let file_extensions = [
".mo", ".csv", ".mat", ".txt", ".json", ".xml", ".svg", ".png", ".jpg",
];
if file_extensions.iter().any(|ext| s.ends_with(ext)) {
return true;
}
if (s.contains('/') || s.contains('\\')) && !s.contains(' ') && s.len() < 200 {
return true;
}
false
}
fn is_valid_url(s: &str) -> bool {
(s.starts_with("http://") || s.starts_with("https://") || s.starts_with("file://"))
&& s.len() > 10
&& !s.contains(char::is_whitespace)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_looks_like_file_path() {
assert!(looks_like_file_path("path/to/file.mo"));
assert!(looks_like_file_path("data.csv"));
assert!(looks_like_file_path("resources/icon.svg"));
assert!(!looks_like_file_path("hello world"));
assert!(!looks_like_file_path("justtext"));
}
#[test]
fn test_is_valid_url() {
assert!(is_valid_url("https://example.com/path"));
assert!(is_valid_url("http://localhost:8080"));
assert!(!is_valid_url("not a url"));
assert!(!is_valid_url("http://"));
}
#[test]
fn test_collect_uris() {
let line = r#"info="See <a href=\"https://example.com\">docs</a>""#;
let mut links = Vec::new();
collect_uris_in_line(line, 0, &mut links);
assert!(!links.is_empty());
}
}