Skip to main content

obsidian_core/
common.rs

1use std::path::{Path, PathBuf};
2
3#[derive(Clone, Debug, PartialEq)]
4pub enum Location {
5    Frontmatter,
6    Inline(InlineLocation),
7}
8
9/// Position of an inline element within a text.
10///
11/// Lines are 1-indexed; columns are 0-indexed and character-based (not byte-based).
12/// `col_end` is exclusive (past-the-end).
13#[derive(Clone, Debug, PartialEq)]
14pub struct InlineLocation {
15    pub line: usize,
16    pub col_start: usize,
17    pub col_end: usize,
18}
19
20/// Normalizes a path by resolving `.`, `..`, and symlink components and making absolute.
21pub(crate) fn normalize_path(path: impl AsRef<Path>, root: Option<&Path>) -> PathBuf {
22    let path = if path.as_ref().is_absolute() {
23        path.as_ref().to_path_buf()
24    } else {
25        if let Some(r) = root {
26            r.to_path_buf().join(path)
27        } else {
28            std::path::absolute(&path).unwrap_or(path.as_ref().to_path_buf())
29        }
30    };
31
32    let mut realpath = PathBuf::new();
33    for component in path.components() {
34        realpath.push(component);
35        realpath = realpath.canonicalize().unwrap_or(realpath);
36    }
37    realpath
38}
39
40/// Computes a relative path from `from_dir` to `to_file`.
41/// Both arguments must be absolute paths.
42pub(crate) fn relative_path(from_dir: impl AsRef<Path>, to_file: impl AsRef<Path>) -> PathBuf {
43    let from: Vec<_> = from_dir.as_ref().components().collect();
44    let to: Vec<_> = to_file.as_ref().components().collect();
45    let common = from.iter().zip(to.iter()).take_while(|(a, b)| a == b).count();
46    let mut result = PathBuf::new();
47    for _ in 0..(from.len() - common) {
48        result.push("..");
49    }
50    for c in &to[common..] {
51        result.push(c);
52    }
53    result
54}
55
56/// Rewrites link spans in `raw_content` according to `replacements`.
57/// Each entry is a `(LocatedLink, new_text)` pair; `new_text` replaces the original span.
58/// Multiple replacements on the same line are applied right-to-left to preserve offsets.
59pub(crate) fn rewrite_links(raw_content: &str, replacements: Vec<(crate::link::LocatedLink, String)>) -> String {
60    use std::collections::HashMap;
61
62    // Map line number (1-indexed) → indices into `replacements`
63    let mut by_line: HashMap<usize, Vec<usize>> = HashMap::new();
64    for (i, (ll, _)) in replacements.iter().enumerate() {
65        by_line.entry(ll.location.line).or_default().push(i);
66    }
67
68    let trailing_newline = raw_content.ends_with('\n');
69    let mut result_lines: Vec<String> = Vec::new();
70
71    for (line_idx, line) in raw_content.lines().enumerate() {
72        let line_num = line_idx + 1;
73        if let Some(indices) = by_line.get(&line_num) {
74            // Sort right-to-left so each splice doesn't shift earlier column offsets
75            let mut sorted = indices.clone();
76            sorted.sort_by(|&a, &b| {
77                replacements[b]
78                    .0
79                    .location
80                    .col_start
81                    .cmp(&replacements[a].0.location.col_start)
82            });
83
84            let mut chars: Vec<char> = line.chars().collect();
85            for idx in sorted {
86                let (ll, new_text) = &replacements[idx];
87                let new_chars: Vec<char> = new_text.chars().collect();
88                chars.splice(ll.location.col_start..ll.location.col_end, new_chars);
89            }
90            result_lines.push(chars.into_iter().collect());
91        } else {
92            result_lines.push(line.to_string());
93        }
94    }
95
96    let mut result = result_lines.join("\n");
97    if trailing_newline {
98        result.push('\n');
99    }
100    result
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106    use std::env::current_dir;
107
108    #[test]
109    fn normalize_path_removes_dot() {
110        assert_eq!(
111            normalize_path(&PathBuf::from("/a/./b"), Some(&current_dir().unwrap())),
112            PathBuf::from("/a/b")
113        );
114    }
115
116    #[test]
117    fn normalize_path_resolves_double_dot() {
118        let cwd = current_dir().unwrap();
119        assert_eq!(normalize_path(&cwd.join("../c"), None), cwd.parent().unwrap().join("c"));
120    }
121
122    #[test]
123    fn normalize_path_deep_traversal() {
124        let cwd = current_dir().unwrap();
125        assert_eq!(
126            normalize_path(&cwd.join("../../../d"), None),
127            cwd.parent().unwrap().parent().unwrap().parent().unwrap().join("d")
128        );
129    }
130
131    #[test]
132    fn normalize_path_traversal_beyond_root_stops_at_root() {
133        let cwd = current_dir().unwrap();
134        assert_eq!(
135            normalize_path(&cwd.join("../../../../../../b"), None),
136            PathBuf::from("/b")
137        );
138    }
139
140    #[test]
141    fn normalize_path_starting_with_single_dot() {
142        let cwd = current_dir().unwrap();
143        assert_eq!(normalize_path(&PathBuf::from("./b"), Some(&cwd.clone())), cwd.join("b"));
144    }
145
146    #[test]
147    fn normalize_path_starting_with_double_dot() {
148        let cwd = current_dir().unwrap();
149        assert_eq!(
150            normalize_path(&PathBuf::from("../b"), Some(&cwd.clone())),
151            cwd.parent().unwrap().join("b")
152        );
153    }
154}