Skip to content

Commit 7c8390f

Browse files
committed
fix(md051): report a broken fragment on a query-string destination once
MD051 and MD057 both record a document's cross-file links in the shared index, spelling them differently: MD051 keeps the destination as written and starts at the link, MD057 keeps the file that destination names and starts at the URL. Deduplication compared the raw strings, so `page.md?raw=true` was stored alongside `page.md` and MD051, which reports every entry, reported the same broken fragment twice at two columns. A link is identified by the file it names, so that is what deduplication now compares. The spelling kept is the destination as written, which is what the message quotes back, and it is kept whichever rule recorded the link first. Keeping one entry means every consumer now reads that spelling, so asking which file a link points at is one shared function that strips the query before resolving. The reverse dependency graph is looked up by the path of a file that changed, and go-to-definition, find-references and rename all compare a link against the file the editor has open; no file is ever called `b.md?raw=true`, so an entry filed or compared under that name is one no lookup reaches. Editing a target would have stopped re-linting the file linking to it, and renaming a heading would have left a link that carries a query pointing at the old anchor.
1 parent 557c490 commit 7c8390f

4 files changed

Lines changed: 309 additions & 21 deletions

File tree

‎src/lsp/navigation.rs‎

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ use super::completion::normalize_path;
1919
use super::position::{byte_to_utf16_offset, utf16_to_byte_offset};
2020
use super::server::RumdlLanguageServer;
2121
use crate::utils::anchor_styles::AnchorStyle;
22-
use crate::workspace_index::PROTOCOL_DOMAIN_REGEX;
22+
use crate::workspace_index::{PROTOCOL_DOMAIN_REGEX, link_target_file};
2323

2424
/// Full link target extracted from a markdown link `[text](file_path#anchor)`.
2525
///
@@ -767,9 +767,7 @@ impl RumdlLanguageServer {
767767
let matching_links: Vec<_> = file_index
768768
.cross_file_links
769769
.iter()
770-
.filter(|link| {
771-
link.is_navigable() && normalize_path(&source_dir.join(&link.target_path)) == *target_file
772-
})
770+
.filter(|link| link.is_navigable() && link_target_file(source_dir, &link.target_path) == *target_file)
773771
.chain(
774772
file_index
775773
.root_relative_links
@@ -907,7 +905,7 @@ impl RumdlLanguageServer {
907905
.iter()
908906
.filter(|link| {
909907
link.is_navigable()
910-
&& normalize_path(&source_dir.join(&link.target_path)) == *target_path
908+
&& link_target_file(source_dir, &link.target_path) == *target_path
911909
&& link.fragment.eq_ignore_ascii_case(fragment)
912910
})
913911
.chain(file_index.root_relative_links.iter().filter(|link| {
@@ -1182,7 +1180,7 @@ impl RumdlLanguageServer {
11821180
.cross_file_links
11831181
.iter()
11841182
.filter(|link| {
1185-
normalize_path(&source_dir.join(&link.target_path)) == *target_path
1183+
link_target_file(source_dir, &link.target_path) == *target_path
11861184
&& link.fragment.eq_ignore_ascii_case(old_anchor)
11871185
})
11881186
.chain(file_index.root_relative_links.iter().filter(|link| {

‎src/lsp/tests.rs‎

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7792,6 +7792,89 @@ async fn test_rename_heading_updates_cross_file_links() {
77927792
);
77937793
}
77947794

7795+
/// A query string is not part of a file name, so `guide.md?raw=true` names
7796+
/// `guide.md`. The index keeps the destination as the document wrote it, which is
7797+
/// the spelling a link's own text has to be edited through, so navigation has to
7798+
/// strip the query when it asks which file a link points at.
7799+
#[tokio::test]
7800+
async fn test_rename_heading_updates_a_cross_file_link_carrying_a_query() {
7801+
use crate::workspace_index::{CrossFileLinkIndex, FileIndex, HeadingIndex, LinkOrigin};
7802+
7803+
let server = create_test_server();
7804+
let docs_dir = test_temp_path("rumdl-rename-test6-query/docs");
7805+
let target_file = docs_dir.join("guide.md");
7806+
let source_file = docs_dir.join("index.md");
7807+
7808+
let target_uri = Url::from_file_path(&target_file).unwrap();
7809+
let source_uri = Url::from_file_path(&source_file).unwrap();
7810+
7811+
let target_content = "## API Reference\n\nAPI docs here.\n";
7812+
server.documents.write().await.insert(
7813+
target_uri.clone(),
7814+
DocumentEntry {
7815+
content: target_content.to_string(),
7816+
version: Some(1),
7817+
from_disk: false,
7818+
},
7819+
);
7820+
7821+
let source_content = "See [api](guide.md?raw=true#api-reference) for details.\n";
7822+
server.documents.write().await.insert(
7823+
source_uri.clone(),
7824+
DocumentEntry {
7825+
content: source_content.to_string(),
7826+
version: Some(1),
7827+
from_disk: false,
7828+
},
7829+
);
7830+
7831+
{
7832+
let mut index = server.workspace_index.write().await;
7833+
7834+
let mut target_fi = FileIndex::default();
7835+
target_fi.add_heading(HeadingIndex {
7836+
text: "API Reference".to_string(),
7837+
auto_anchor: "api-reference".to_string(),
7838+
custom_anchor: None,
7839+
line: 1,
7840+
is_setext: false,
7841+
});
7842+
index.insert_file(target_file.clone(), target_fi);
7843+
7844+
let mut source_fi = FileIndex::default();
7845+
source_fi.add_cross_file_link(CrossFileLinkIndex {
7846+
// The spelling production leaves in the index for this link.
7847+
target_path: "guide.md?raw=true".to_string(),
7848+
fragment: "api-reference".to_string(),
7849+
line: 1,
7850+
column: 11, // byte column of the destination in the link
7851+
origin: LinkOrigin::Body,
7852+
});
7853+
index.insert_file(source_file.clone(), source_fi);
7854+
}
7855+
7856+
let position = Position { line: 0, character: 5 };
7857+
let result = server.handle_rename(&target_uri, position, "REST API").await;
7858+
assert!(result.is_some(), "Should produce workspace edit");
7859+
7860+
let edit = result.unwrap();
7861+
let changes = edit.changes.unwrap();
7862+
7863+
let target_edits = changes.get(&target_uri).expect("Should have target edits");
7864+
assert!(
7865+
target_edits.iter().any(|e| e.new_text == "REST API"),
7866+
"Should rename the heading text"
7867+
);
7868+
7869+
let source_edits = changes
7870+
.get(&source_uri)
7871+
.expect("a link carrying a query still points at the renamed file");
7872+
assert!(
7873+
source_edits.iter().any(|e| e.new_text == "rest-api"),
7874+
"Should update the cross-file link anchor"
7875+
);
7876+
}
7877+
77957878
#[tokio::test]
77967879
async fn test_rename_refuses_empty_name() {
77977880
use crate::workspace_index::{FileIndex, HeadingIndex};

‎src/workspace_index.rs‎

Lines changed: 171 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,19 @@ fn strip_query_and_fragment(url: &str) -> &str {
118118
}
119119
}
120120

121+
/// The file a directory-relative link names, resolved against the directory
122+
/// holding the document that wrote it.
123+
///
124+
/// The index keeps a destination as the document spelled it, because that is the
125+
/// text an edit to the link has to be measured against, and a spelling can carry
126+
/// a query string. A query is not part of a file name - no file is ever called
127+
/// `b.md?raw=true` - so it is stripped here. Every consumer asking which file a
128+
/// link points at goes through this, so the index's own keys and the answers
129+
/// navigation gives cannot disagree.
130+
pub fn link_target_file(source_dir: &Path, target_path: &str) -> PathBuf {
131+
WorkspaceIndex::normalize_path(&source_dir.join(strip_query_and_fragment(target_path)))
132+
}
133+
121134
/// Markdown file links extracted from a document, split by how they resolve.
122135
///
123136
/// Linting rules only understand `relative` links (resolved against the source
@@ -270,8 +283,14 @@ const CACHE_MAGIC: &[u8; 4] = b"RWSI";
270283
/// no longer correct. Version 9 adds `CrossFileLinkIndex::origin`; postcard is
271284
/// not self-describing, so a version 8 cache would decode the following field's
272285
/// bytes as the new one and yield nonsense.
286+
///
287+
/// Version 10 changes what `cross_file_links` holds rather than how it is laid
288+
/// out: one link is now one entry however each rule spells the destination. The
289+
/// bytes still decode, so nothing here would notice, and a cached index is
290+
/// reused whole when a file's content is unchanged - a version 9 cache would
291+
/// keep reporting the duplicate this version exists to stop.
273292
#[cfg(feature = "postcard")]
274-
const CACHE_FORMAT_VERSION: u32 = 9;
293+
const CACHE_FORMAT_VERSION: u32 = 10;
275294

276295
/// Cache file name within the version directory
277296
#[cfg(feature = "postcard")]
@@ -757,15 +776,13 @@ impl WorkspaceIndex {
757776
}
758777

759778
/// Resolve a relative path from a source file to an absolute target path
779+
///
780+
/// This keys the reverse dependency graph, which is looked up by the path of
781+
/// a file that changed, so it has to answer with a file name - the same
782+
/// question [`link_target_file`] answers for every other consumer.
760783
fn resolve_target_path(&self, source_file: &Path, relative_target: &str) -> PathBuf {
761-
// Get the directory containing the source file
762784
let source_dir = source_file.parent().unwrap_or(Path::new(""));
763-
764-
// Join with the relative target and normalize
765-
let class=pl-kos>.join(relative_target);
766-
767-
// Normalize the path (handle .., ., etc.)
768-
Self::normalize_path(&target)
785+
link_target_file(source_dir, relative_target)
769786
}
770787

771788
/// Normalize a path by resolving . and .. components
@@ -964,15 +981,35 @@ impl FileIndex {
964981
false
965982
}
966983

967-
/// Add a cross-file link to the index (deduplicates by target_path, fragment, line)
984+
/// Add a cross-file link to the index, keyed on the file it names, the
985+
/// fragment it asks for, and the line it sits on.
986+
///
987+
/// Several rules contribute the same link and spell it differently: MD051
988+
/// records the destination as written and starts at the link, MD057 records
989+
/// the file that destination names and starts at the URL. Neither the string
990+
/// nor the column can identify a link, so the file it names does - comparing
991+
/// the raw strings let `page.md?raw=true` in as a second entry alongside
992+
/// `page.md`, and MD051, which reports every entry, then reported the same
993+
/// broken fragment twice.
994+
///
995+
/// One key per line is what the index has always recorded, so two links on
996+
/// one line asking the same file for the same fragment are one entry
997+
/// however each of them spells the destination.
968998
pub fn add_cross_file_link(&mut self, link: CrossFileLinkIndex) {
969-
// Deduplicate: multiple rules may contribute the same link with different columns
970-
// (e.g., MD051 uses link start, MD057 uses URL start)
971-
let is_duplicate = self.cross_file_links.iter().any(|existing| {
972-
existing.target_path == link.target_path && existing.fragment == link.fragment && existing.line == link.line
999+
let existing = self.cross_file_links.iter_mut().find(|existing| {
1000+
existing.fragment == link.fragment
1001+
&& existing.line == link.line
1002+
&& strip_query_and_fragment(&existing.target_path) == strip_query_and_fragment(&link.target_path)
9731003
});
974-
if !is_duplicate {
975-
self.cross_file_links.push(link);
1004+
match existing {
1005+
// A message quotes the destination back, so the spelling that kept the
1006+
// query string is the one to keep, whichever rule recorded it first.
1007+
Some(existing) => {
1008+
if !existing.target_path.contains('?') && link.target_path.contains('?') {
1009+
*existing = link;
1010+
}
1011+
}
1012+
None => self.cross_file_links.push(link),
9761013
}
9771014
}
9781015

@@ -1175,6 +1212,125 @@ mod tests {
11751212
);
11761213
}
11771214

1215+
/// Two rules record the same link, one keeping the query string and one
1216+
/// keeping only the file it names. That is one link, and the spelling kept
1217+
/// is the destination as written whichever rule got there first - a message
1218+
/// quotes it back, so the answer must not depend on rule order.
1219+
#[test]
1220+
fn test_add_cross_file_link_keeps_the_destination_as_written() {
1221+
let as_written = CrossFileLinkIndex {
1222+
target_path: "other.md?raw=true".to_string(),
1223+
fragment: "missing".to_string(),
1224+
line: 3,
1225+
column: 1,
1226+
origin: LinkOrigin::Body,
1227+
};
1228+
let file_named = CrossFileLinkIndex {
1229+
target_path: "other.md".to_string(),
1230+
fragment: "missing".to_string(),
1231+
line: 3,
1232+
column: 9,
1233+
origin: LinkOrigin::Body,
1234+
};
1235+
1236+
for (first, second) in [
1237+
(as_written.clone(), file_named.clone()),
1238+
(file_named.clone(), as_written.clone()),
1239+
] {
1240+
let mut index = FileIndex::new();
1241+
index.add_cross_file_link(first);
1242+
index.add_cross_file_link(second);
1243+
1244+
assert_eq!(
1245+
index.cross_file_links.len(),
1246+
1,
1247+
"one link is one entry, got: {:?}",
1248+
index.cross_file_links
1249+
);
1250+
assert_eq!(index.cross_file_links[0].target_path, "other.md?raw=true");
1251+
}
1252+
}
1253+
1254+
/// Links to two different files are two entries, so the deduplication above
1255+
/// cannot swallow a second target.
1256+
#[test]
1257+
fn test_add_cross_file_link_keeps_distinct_targets() {
1258+
let mut index = FileIndex::new();
1259+
for target in ["one.md", "two.md"] {
1260+
index.add_cross_file_link(CrossFileLinkIndex {
1261+
target_path: target.to_string(),
1262+
fragment: "missing".to_string(),
1263+
line: 3,
1264+
column: 1,
1265+
origin: LinkOrigin::Body,
1266+
});
1267+
}
1268+
assert_eq!(index.cross_file_links.len(), 2);
1269+
}
1270+
1271+
/// Two links on one line asking the same file for the same fragment are one
1272+
/// entry, which is what the index has always recorded for two identically
1273+
/// spelled destinations. Differing query strings do not make them two links,
1274+
/// because a query string is not part of a file name.
1275+
///
1276+
/// A different fragment, or the same link on another line, stays its own
1277+
/// entry - so this is a boundary, not a blanket collapse to one finding.
1278+
#[test]
1279+
fn test_add_cross_file_link_collapses_one_line_asking_one_file_once() {
1280+
let link = |target: &str, fragment: &str, line: usize| CrossFileLinkIndex {
1281+
target_path: target.to_string(),
1282+
fragment: fragment.to_string(),
1283+
line,
1284+
column: 1,
1285+
origin: LinkOrigin::Body,
1286+
};
1287+
1288+
let mut index = FileIndex::new();
1289+
index.add_cross_file_link(link("target.md?raw=true", "missing", 3));
1290+
index.add_cross_file_link(link("target.md?plain=1", "missing", 3));
1291+
assert_eq!(
1292+
index.cross_file_links.len(),
1293+
1,
1294+
"one file, one fragment, one line is one entry, got: {:?}",
1295+
index.cross_file_links
1296+
);
1297+
1298+
index.add_cross_file_link(link("target.md", "other", 3));
1299+
index.add_cross_file_link(link("target.md", "missing", 4));
1300+
assert_eq!(index.cross_file_links.len(), 3);
1301+
}
1302+
1303+
/// A link is a dependency on the file it names, so editing `b.md` re-lints
1304+
/// the source whichever way that source spelled the destination. The query
1305+
/// string is the case that gets this wrong: it is not part of a file name,
1306+
/// nothing ever creates a file called `b.md?raw=true`, and a reverse
1307+
/// dependency filed under that name is one no editor will ever look up.
1308+
#[test]
1309+
fn test_reverse_deps_ignore_a_query_string_on_the_destination() {
1310+
let mut index = WorkspaceIndex::new();
1311+
1312+
let mut file_a = FileIndex::new();
1313+
file_a.add_cross_file_link(CrossFileLinkIndex {
1314+
target_path: "b.md?raw=true".to_string(),
1315+
fragment: "section".to_string(),
1316+
line: 10,
1317+
column: 5,
1318+
origin: LinkOrigin::Body,
1319+
});
1320+
index.update_file(Path::new("docs/a.md"), file_a);
1321+
1322+
assert_eq!(
1323+
index.get_dependents(Path::new("docs/b.md")),
1324+
vec![PathBuf::from("docs/a.md")],
1325+
"editing docs/b.md must re-lint the file linking to it"
1326+
);
1327+
1328+
// And the source stops depending on it once the link is gone, so the
1329+
// stripped key is cleared by the same route it was created.
1330+
index.update_file(Path::new("docs/a.md"), FileIndex::new());
1331+
assert!(index.get_dependents(Path::new("docs/b.md")).is_empty());
1332+
}
1333+
11781334
#[test]
11791335
fn test_reverse_deps_basic() {
11801336
let mut index = WorkspaceIndex::new();

0 commit comments

Comments
 (0)