use std::path::{Path, PathBuf};
use tower_lsp::lsp_types::*;
use crate::linguist_data::{CANONICAL_TO_ALIASES, default_alias};
use crate::rule_config_serde::load_rule_config;
use crate::rules::md040_fenced_code_language::md040_config::MD040Config;
use super::server::RumdlLanguageServer;
pub(crate) struct LinkTargetInfo {
pub(crate) file_path: String,
pub(crate) path_start_col: u32,
pub(crate) anchor: Option<(String, u32)>,
}
impl RumdlLanguageServer {
pub(super) fn detect_code_fence_language_position(text: &str, position: Position) -> Option<(u32, String)> {
let line_num = position.line as usize;
let utf16_cursor = position.character as usize;
let lines: Vec<&str> = text.lines().collect();
if line_num >= lines.len() {
return None;
}
let line = lines[line_num];
let trimmed = line.trim_start();
let indent = line.len() - trimmed.len();
let (fence_char, fence_len) = if trimmed.starts_with('`') {
let count = trimmed.chars().take_while(|&c| c == '`').count();
if count >= 3 {
('`', count)
} else {
return None;
}
} else if trimmed.starts_with('~') {
let count = trimmed.chars().take_while(|&c| c == '~').count();
if count >= 3 {
('~', count)
} else {
return None;
}
} else {
return None;
};
let fence_end_byte = indent + fence_len;
if utf16_cursor < fence_end_byte {
return None;
}
let is_closing_fence = Self::is_closing_fence(&lines[..line_num], fence_char, fence_len);
if is_closing_fence {
return None;
}
let byte_cursor = utf16_to_byte_offset(line, utf16_cursor).unwrap_or(line.len());
let current_text = &line[fence_end_byte..byte_cursor.min(line.len())];
if current_text.contains(' ') {
return None;
}
Some((fence_end_byte as u32, current_text.to_string()))
}
pub(super) fn is_closing_fence(previous_lines: &[&str], fence_char: char, fence_len: usize) -> bool {
let mut open_fences: Vec<(char, usize)> = Vec::new();
for line in previous_lines {
let trimmed = line.trim_start();
let (line_fence_char, line_fence_len) = if trimmed.starts_with('`') {
let count = trimmed.chars().take_while(|&c| c == '`').count();
if count >= 3 {
('`', count)
} else {
continue;
}
} else if trimmed.starts_with('~') {
let count = trimmed.chars().take_while(|&c| c == '~').count();
if count >= 3 {
('~', count)
} else {
continue;
}
} else {
continue;
};
if let Some(pos) = open_fences
.iter()
.rposition(|(c, len)| *c == line_fence_char && line_fence_len >= *len)
{
let after_fence = &trimmed[line_fence_len..].trim();
if after_fence.is_empty() {
open_fences.truncate(pos);
continue;
}
}
open_fences.push((line_fence_char, line_fence_len));
}
open_fences.iter().any(|(c, len)| *c == fence_char && fence_len >= *len)
}
pub(super) async fn get_language_completions(
&self,
uri: &Url,
current_text: &str,
start_col: u32,
position: Position,
) -> Vec<CompletionItem> {
let file_path = uri.to_file_path().ok();
let config = if let Some(ref path) = file_path {
self.resolve_config_for_file(path).await
} else {
self.rumdl_config.read().await.clone()
};
let md040_config: MD040Config = load_rule_config(&config);
let mut items = Vec::new();
let current_lower = current_text.to_lowercase();
let mut language_entries: Vec<(String, String, bool)> = Vec::new();
for (canonical, aliases) in CANONICAL_TO_ALIASES.iter() {
if !md040_config.allowed_languages.is_empty()
&& !md040_config
.allowed_languages
.iter()
.any(|a| a.eq_ignore_ascii_case(canonical))
{
continue;
}
if md040_config
.disallowed_languages
.iter()
.any(|d| d.eq_ignore_ascii_case(canonical))
{
continue;
}
let preferred = md040_config
.preferred_aliases
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(canonical))
.map(|(_, v)| v.clone())
.or_else(|| default_alias(canonical).map(std::string::ToString::to_string))
.unwrap_or_else(|| (*canonical).to_string());
language_entries.push(((*canonical).to_string(), preferred.clone(), true));
for &alias in aliases {
if alias != preferred {
language_entries.push(((*canonical).to_string(), alias.to_string(), false));
}
}
}
for (canonical, alias, is_default) in language_entries {
if !current_text.is_empty() && !alias.to_lowercase().starts_with(¤t_lower) {
continue;
}
let sort_priority = if is_default { "0" } else { "1" };
let item = CompletionItem {
label: alias.clone(),
kind: Some(CompletionItemKind::VALUE),
detail: Some(format!("{canonical} (GitHub Linguist)")),
documentation: None,
sort_text: Some(format!("{sort_priority}{alias}")),
filter_text: Some(alias.clone()),
insert_text: Some(alias.clone()),
text_edit: Some(CompletionTextEdit::Edit(TextEdit {
range: Range {
start: Position {
line: position.line,
character: start_col,
},
end: position,
},
new_text: alias,
})),
..Default::default()
};
items.push(item);
}
items.truncate(100);
items
}
pub(super) fn detect_link_target_position(text: &str, position: Position) -> Option<LinkTargetInfo> {
let line_num = position.line as usize;
let utf16_cursor = position.character as usize;
let lines: Vec<&str> = text.lines().collect();
if line_num >= lines.len() {
return None;
}
let line = lines[line_num];
let byte_cursor = utf16_to_byte_offset(line, utf16_cursor)?;
let before_cursor = &line[..byte_cursor];
let link_open = before_cursor.rfind("](")?;
let content_start = link_open + 2; let content = &before_cursor[content_start..];
if content.contains(')') {
return None;
}
let backtick_count = before_cursor[..link_open].chars().filter(|&c| c == '`').count();
if backtick_count % 2 != 0 {
return None;
}
let path_start_col = byte_to_utf16_offset(line, content_start);
if let Some(hash_pos) = content.find('#') {
let file_path = content[..hash_pos].to_string();
let partial_anchor = content[hash_pos + 1..].to_string();
let anchor_start_col = byte_to_utf16_offset(line, content_start + hash_pos + 1);
Some(LinkTargetInfo {
file_path,
path_start_col,
anchor: Some((partial_anchor, anchor_start_col)),
})
} else {
Some(LinkTargetInfo {
file_path: content.to_string(),
path_start_col,
anchor: None,
})
}
}
pub(super) async fn get_file_completions(
&self,
uri: &Url,
partial_path: &str,
start_col: u32,
position: Position,
) -> CompletionList {
if partial_path.starts_with('/') {
return self
.get_absolute_path_completions(partial_path, start_col, position)
.await;
}
let Ok(current_file) = uri.to_file_path() else {
return CompletionList::default();
};
let Some(current_dir) = current_file.parent().map(std::path::Path::to_path_buf) else {
return CompletionList::default();
};
let index = self.workspace_index.read().await;
let partial_lower = partial_path.to_lowercase();
let mut matches: Vec<(usize, String)> = Vec::new();
for (file_path, _) in index.files() {
if file_path == current_file.as_path() {
continue;
}
let rel = make_relative_path(¤t_dir, file_path);
let rel_str = rel.to_string_lossy().replace('\\', "/");
if !partial_path.is_empty() && !rel_str.to_lowercase().starts_with(&partial_lower) {
continue;
}
matches.push((path_distance(&rel), rel_str));
}
matches.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
const MAX_ITEMS: usize = 50;
let is_incomplete = matches.len() > MAX_ITEMS;
matches.truncate(MAX_ITEMS);
let items = matches
.into_iter()
.map(|(distance, rel_str)| CompletionItem {
label: rel_str.clone(),
kind: Some(CompletionItemKind::FILE),
detail: Some("Markdown file".to_string()),
sort_text: Some(format!("{distance:04}{rel_str}")),
filter_text: Some(rel_str.clone()),
insert_text: Some(rel_str.clone()),
text_edit: Some(CompletionTextEdit::Edit(TextEdit {
range: Range {
start: Position {
line: position.line,
character: start_col,
},
end: position,
},
new_text: rel_str.clone(),
})),
..Default::default()
})
.collect();
CompletionList { is_incomplete, items }
}
async fn get_absolute_path_completions(
&self,
partial_path: &str,
start_col: u32,
position: Position,
) -> CompletionList {
let content_roots = self.resolve_content_roots().await;
if content_roots.is_empty() {
return CompletionList::default();
}
let last_slash = partial_path.rfind('/').unwrap_or(0);
let dir_part = &partial_path[..=last_slash];
let file_prefix = &partial_path[last_slash + 1..];
let rel_dir = dir_part.trim_start_matches('/');
let prefix_lower = file_prefix.to_lowercase();
if Path::new(rel_dir)
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
return CompletionList::default();
}
let mut seen = std::collections::HashSet::new();
let mut items = Vec::new();
for root in &content_roots {
let base = if rel_dir.is_empty() {
root.clone()
} else {
normalize_path(&root.join(rel_dir))
};
let walker = ignore::WalkBuilder::new(&base)
.max_depth(Some(1))
.hidden(true)
.git_ignore(true)
.git_global(true)
.git_exclude(true)
.parents(true)
.require_git(false)
.build();
for entry in walker.flatten() {
if entry.depth() == 0 {
continue; }
let name = entry.file_name().to_string_lossy().to_string();
if !file_prefix.is_empty() && !name.to_lowercase().starts_with(&prefix_lower) {
continue;
}
let is_dir = entry.file_type().is_some_and(|t| t.is_dir());
let new_text = if is_dir {
format!("{dir_part}{name}/")
} else {
format!("{dir_part}{name}")
};
if !seen.insert(new_text.clone()) {
continue; }
let label = if is_dir { format!("{name}/") } else { name.clone() };
items.push(CompletionItem {
label: label.clone(),
kind: Some(if is_dir {
CompletionItemKind::FOLDER
} else {
CompletionItemKind::FILE
}),
detail: Some(if is_dir { "Directory" } else { "File" }.to_string()),
sort_text: Some(format!("{}{}", if is_dir { '0' } else { '1' }, label)),
filter_text: Some(new_text.clone()),
text_edit: Some(CompletionTextEdit::Edit(TextEdit {
range: Range {
start: Position {
line: position.line,
character: start_col,
},
end: position,
},
new_text,
})),
command: is_dir.then(|| Command {
title: "Trigger Suggest".to_string(),
command: "editor.action.triggerSuggest".to_string(),
arguments: None,
}),
..Default::default()
});
}
}
CompletionList {
is_incomplete: true,
items,
}
}
pub(super) async fn resolve_content_roots(&self) -> Vec<PathBuf> {
let configured = self.config.read().await.link_completion_content_roots.clone();
let roots = self.workspace_roots.read().await;
if configured.is_empty() {
return roots.clone();
}
let mut out = Vec::new();
for entry in &configured {
let path = PathBuf::from(entry);
if path.is_absolute() {
out.push(path);
} else {
for root in roots.iter() {
out.push(normalize_path(&root.join(&path)));
}
}
}
out
}
pub(super) async fn resolve_link_path(&self, current_file: &Path, file_path: &str) -> Option<PathBuf> {
if file_path.is_empty() {
return Some(current_file.to_path_buf());
}
if let Some(rel) = file_path.strip_prefix('/') {
if Path::new(rel)
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
return None;
}
let content_roots = self.resolve_content_roots().await;
let candidates: Vec<PathBuf> = content_roots
.iter()
.map(|root| normalize_path(&root.join(rel)))
.collect();
let indexed = {
let index = self.workspace_index.read().await;
candidates.iter().find(|c| index.get_file(c).is_some()).cloned()
};
return indexed.or_else(|| candidates.into_iter().find(|c| c.is_file()));
}
let current_dir = current_file.parent()?;
Some(normalize_path(¤t_dir.join(file_path)))
}
pub(super) async fn get_anchor_completions(
&self,
uri: &Url,
file_path: &str,
partial_anchor: &str,
start_col: u32,
position: Position,
) -> Vec<CompletionItem> {
let Ok(current_file) = uri.to_file_path() else {
return Vec::new();
};
let Some(target) = self.resolve_link_path(¤t_file, file_path).await else {
return Vec::new();
};
let allow_disk_fallback = file_path.starts_with('/');
let indexed_headings = {
let index = self.workspace_index.read().await;
index.get_file(&target).map(|fi| fi.headings.clone())
};
let headings = match indexed_headings {
Some(headings) => headings,
None if allow_disk_fallback => match tokio::fs::read_to_string(&target).await {
Ok(content) => crate::lsp::index_worker::IndexWorker::build_file_index(&content).headings,
Err(_) => return Vec::new(),
},
None => return Vec::new(),
};
let partial_lower = partial_anchor.to_lowercase();
let mut items = Vec::new();
for heading in &headings {
let anchor = heading.custom_anchor.as_deref().unwrap_or(&heading.auto_anchor);
if !partial_anchor.is_empty() && !anchor.to_lowercase().starts_with(&partial_lower) {
continue;
}
let item = CompletionItem {
label: heading.text.clone(),
kind: Some(CompletionItemKind::REFERENCE),
detail: Some(format!("#{anchor}")),
sort_text: Some(format!("{:06}", heading.line)),
filter_text: Some(anchor.to_string()),
insert_text: Some(anchor.to_string()),
text_edit: Some(CompletionTextEdit::Edit(TextEdit {
range: Range {
start: Position {
line: position.line,
character: start_col,
},
end: position,
},
new_text: anchor.to_string(),
})),
..Default::default()
};
items.push(item);
}
items.truncate(50);
items
}
}
fn make_relative_path(from_dir: &Path, to_file: &Path) -> PathBuf {
let from_comps: Vec<_> = from_dir.components().collect();
let to_comps: Vec<_> = to_file.components().collect();
let common_len = from_comps
.iter()
.zip(to_comps.iter())
.take_while(|(a, b)| a == b)
.count();
let mut rel = PathBuf::new();
for _ in &from_comps[common_len..] {
rel.push("..");
}
for comp in &to_comps[common_len..] {
rel.push(comp);
}
rel
}
fn path_distance(rel: &Path) -> usize {
rel.components()
.take_while(|c| matches!(c, std::path::Component::ParentDir))
.count()
}
pub(super) fn normalize_path(path: &std::path::Path) -> PathBuf {
let mut result = PathBuf::new();
for component in path.components() {
match component {
std::path::Component::ParentDir => {
result.pop();
}
std::path::Component::CurDir => {}
c => result.push(c),
}
}
result
}
pub(super) fn utf16_to_byte_offset(s: &str, utf16_offset: usize) -> Option<usize> {
let mut byte_pos = 0;
let mut utf16_pos = 0;
for ch in s.chars() {
if utf16_pos >= utf16_offset {
return Some(byte_pos);
}
byte_pos += ch.len_utf8();
utf16_pos += ch.len_utf16();
}
if utf16_pos >= utf16_offset {
Some(byte_pos)
} else {
None
}
}
pub(super) fn byte_to_utf16_offset(s: &str, byte_offset: usize) -> u32 {
s[..byte_offset].chars().map(|c| c.len_utf16() as u32).sum()
}