use std::path::{Component, Path, PathBuf};
use markdown::mdast::{InlineCode, Node, Text};
use crate::config::Config;
#[derive(Clone, Debug)]
pub(crate) struct CandidateReference {
pub(crate) display_text: String,
pub(crate) uses_relative_syntax: bool,
pub(crate) uses_workspace_root_syntax: bool,
pub(crate) is_directory_like: bool,
}
#[derive(Clone, Debug)]
pub(crate) struct ResolvedReference {
pub(crate) repo_relative_path: String,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum ReferenceKind {
Backtick,
Link,
}
pub(crate) fn classify_inline_reference(
config: &Config,
value: &str,
) -> Option<CandidateReference> {
if value.is_empty() || is_external(value) || contains_disallowed_backtick_syntax(value) {
return None;
}
let uses_relative_syntax = has_relative_prefix(value);
let uses_workspace_root_syntax = has_workspace_root_prefix(value);
if uses_workspace_root_syntax
|| uses_relative_syntax
|| value.ends_with('/')
|| value.ends_with('\\')
{
return Some(CandidateReference {
display_text: value.to_string(),
uses_relative_syntax,
uses_workspace_root_syntax,
is_directory_like: value.ends_with('/') || value.ends_with('\\'),
});
}
let path = Path::new(value);
if let Some(extension) = path.extension().and_then(|value| value.to_str()) {
let extension = format!(".{extension}");
if config.known_extensions.contains(&extension) {
return Some(CandidateReference {
display_text: value.to_string(),
uses_relative_syntax: false,
uses_workspace_root_syntax: false,
is_directory_like: false,
});
}
}
if config.special_filenames.contains(value) {
return Some(CandidateReference {
display_text: value.to_string(),
uses_relative_syntax: false,
uses_workspace_root_syntax: false,
is_directory_like: false,
});
}
None
}
pub(crate) fn classify_link_reference(config: &Config, value: &str) -> Option<CandidateReference> {
if value.is_empty() || is_external(value) {
return None;
}
let uses_relative_syntax = has_relative_prefix(value);
let uses_workspace_root_syntax = has_workspace_root_prefix(value);
if uses_workspace_root_syntax
|| value.contains('/')
|| value.contains('\\')
|| uses_relative_syntax
{
return Some(CandidateReference {
display_text: value.to_string(),
uses_relative_syntax,
uses_workspace_root_syntax,
is_directory_like: value.ends_with('/') || value.ends_with('\\'),
});
}
let path = Path::new(value);
if let Some(extension) = path.extension().and_then(|value| value.to_str()) {
let extension = format!(".{extension}");
if config.known_extensions.contains(&extension) {
return Some(CandidateReference {
display_text: value.to_string(),
uses_relative_syntax: false,
uses_workspace_root_syntax: false,
is_directory_like: false,
});
}
}
if config.special_filenames.contains(value) {
return Some(CandidateReference {
display_text: value.to_string(),
uses_relative_syntax: false,
uses_workspace_root_syntax: false,
is_directory_like: false,
});
}
None
}
pub(crate) fn resolve_candidate(
file: &str,
candidate: &CandidateReference,
kind: ReferenceKind,
) -> Option<ResolvedReference> {
let display_text = candidate.display_text.replace('\\', "/");
let normalized_display = display_text.trim_start_matches('/');
let base = match kind {
ReferenceKind::Backtick => {
if candidate.uses_workspace_root_syntax {
Path::new("")
} else if candidate.uses_relative_syntax {
Path::new(file).parent().unwrap_or_else(|| Path::new(""))
} else {
Path::new("")
}
}
ReferenceKind::Link => {
if candidate.uses_workspace_root_syntax {
Path::new("")
} else {
Path::new(file).parent().unwrap_or_else(|| Path::new(""))
}
}
};
let joined = base.join(normalized_display);
let normalized = normalize_path(joined)?;
Some(ResolvedReference {
repo_relative_path: normalized,
})
}
fn normalize_path(candidate: PathBuf) -> Option<String> {
let mut parts = Vec::new();
for component in candidate.components() {
match component {
Component::CurDir => {}
Component::Normal(value) => parts.push(value.to_string_lossy().to_string()),
Component::ParentDir => {
if parts.is_empty() {
return None;
}
parts.pop();
}
_ => return None,
}
}
if parts.is_empty() {
None
} else {
Some(parts.join("/"))
}
}
pub(crate) fn render_repo_relative(resolved: &ResolvedReference, exists_path: &Path) -> String {
if exists_path.is_dir() {
format!("{}/", resolved.repo_relative_path)
} else {
resolved.repo_relative_path.clone()
}
}
pub(crate) fn render_link_destination(
file: &str,
candidate: &CandidateReference,
resolved: &ResolvedReference,
exists_path: &Path,
) -> String {
if candidate.uses_workspace_root_syntax {
return format!("/{}", render_repo_relative(resolved, exists_path));
}
let from_dir = Path::new(file).parent().unwrap_or_else(|| Path::new(""));
render_relative_path(
from_dir,
Path::new(&resolved.repo_relative_path),
exists_path.is_dir(),
)
}
fn render_relative_path(from_dir: &Path, target: &Path, is_directory: bool) -> String {
let from_parts: Vec<_> = from_dir
.components()
.filter_map(component_to_string)
.collect();
let target_parts: Vec<_> = target
.components()
.filter_map(component_to_string)
.collect();
let mut shared = 0;
while shared < from_parts.len()
&& shared < target_parts.len()
&& from_parts[shared] == target_parts[shared]
{
shared += 1;
}
let mut parts = Vec::new();
for _ in shared..from_parts.len() {
parts.push("..".to_string());
}
for part in &target_parts[shared..] {
parts.push(part.clone());
}
let mut rendered = if parts.is_empty() {
".".to_string()
} else {
parts.join("/")
};
if is_directory && !rendered.ends_with('/') {
rendered.push('/');
}
rendered
}
fn component_to_string(component: Component<'_>) -> Option<String> {
match component {
Component::Normal(value) => Some(value.to_string_lossy().to_string()),
_ => None,
}
}
pub(crate) fn is_external(value: &str) -> bool {
matches!(
value,
v if v.starts_with("http://")
|| v.starts_with("https://")
|| v.starts_with("mailto:")
|| v.starts_with('#')
)
}
pub(crate) fn contains_disallowed_backtick_syntax(value: &str) -> bool {
value.contains("//")
|| value.contains("...")
|| value.chars().any(|ch| {
ch.is_whitespace()
|| matches!(
ch,
'*' | '?' | '[' | '{' | ':' | '(' | ')' | '<' | '>' | '"' | '\''
)
})
}
fn has_workspace_root_prefix(value: &str) -> bool {
value.starts_with('/')
}
fn has_relative_prefix(value: &str) -> bool {
value.starts_with("./") || value.starts_with("../")
}
pub(crate) fn label_text(children: &[Node]) -> String {
let mut text = String::new();
for child in children {
match child {
Node::Text(Text { value, .. }) => text.push_str(value),
Node::InlineCode(InlineCode { value, .. }) => text.push_str(value),
Node::Link(link) => text.push_str(&label_text(&link.children)),
_ => {}
}
}
text
}
#[cfg(test)]
mod tests {
use super::{
CandidateReference, ReferenceKind, render_link_destination, render_repo_relative,
resolve_candidate,
};
use tempfile::TempDir;
#[test]
fn resolve_candidate_normalizes_relative_segments_and_separators() {
let candidate = CandidateReference {
display_text: "./nested\\..\\real.md".to_string(),
uses_relative_syntax: true,
uses_workspace_root_syntax: false,
is_directory_like: false,
};
let resolved =
resolve_candidate("docs/guide.md", &candidate, ReferenceKind::Backtick).unwrap();
assert_eq!(resolved.repo_relative_path, "docs/real.md");
}
#[test]
fn resolve_candidate_rejects_escape_above_repository_root() {
let candidate = CandidateReference {
display_text: "../../../secret.md".to_string(),
uses_relative_syntax: true,
uses_workspace_root_syntax: false,
is_directory_like: false,
};
assert!(resolve_candidate("docs/guide.md", &candidate, ReferenceKind::Backtick).is_none());
assert!(resolve_candidate("docs/guide.md", &candidate, ReferenceKind::Link).is_none());
}
#[test]
fn render_link_destination_keeps_workspace_root_and_directory_suffix() {
let temp = TempDir::new().unwrap();
let docs = temp.path().join("docs");
std::fs::create_dir(&docs).unwrap();
let candidate = CandidateReference {
display_text: "/docs".to_string(),
uses_relative_syntax: false,
uses_workspace_root_syntax: true,
is_directory_like: true,
};
let resolved = resolve_candidate("README.md", &candidate, ReferenceKind::Link).unwrap();
let rendered = render_link_destination("README.md", &candidate, &resolved, &docs);
assert_eq!(rendered, "/docs/");
assert_eq!(render_repo_relative(&resolved, &docs), "docs/");
}
}