diff --git a/src/bin/patto-lsp.rs b/src/bin/patto-lsp.rs index 146111c..0e7f40d 100644 --- a/src/bin/patto-lsp.rs +++ b/src/bin/patto-lsp.rs @@ -194,6 +194,33 @@ fn find_anchor(parent: &AstNode, anchor: &str) -> Option { .map(|x| x.clone()); } +/// Find anchor definition at the given row and column position +/// Returns (anchor_name, anchor_location) if cursor is on an anchor definition +fn find_anchor_at_position( + parent: &AstNode, + row: usize, + col: usize, +) -> Option<(String, parser::Location)> { + if let AstNodeKind::Line { ref properties } = &parent.kind() { + if parent.location().row == row { + for prop in properties { + if let Property::Anchor { name, location } = prop { + if location.span.contains(col) { + return Some((name.clone(), location.clone())); + } + } + } + } + } + + for child in parent.value().children.lock().unwrap().iter() { + if let Some(result) = find_anchor_at_position(child, row, col) { + return Some(result); + } + } + None +} + fn locate_node_route(parent: &AstNode, row: usize, col: usize) -> Option> { if let Some(route) = locate_node_route_impl(parent, row, col) { //route.reverse(); @@ -1243,6 +1270,26 @@ impl LanguageServer for Backend { let line_str = line.as_str()?; let posbyte = utf16_to_byte_idx(line_str, position.character as usize); + // Try to find anchor definition at cursor + if let Some((anchor_name, anchor_loc)) = + find_anchor_at_position(&ast, position.line as usize, posbyte) + { + // Return range of the anchor name (excluding # prefix for short form, or {@anchor } for long form) + // The location includes the full anchor expression + let start_char = utf16_from_byte_idx(line_str, anchor_loc.span.0) as u32; + let end_char = utf16_from_byte_idx(line_str, anchor_loc.span.1) as u32; + + let range = Range::new( + Position::new(position.line, start_char), + Position::new(position.line, end_char), + ); + + return Some(PrepareRenameResponse::RangeWithPlaceholder { + range, + placeholder: anchor_name, + }); + } + // Try to find WikiLink at cursor if let Some(node_route) = locate_node_route(&ast, position.line as usize, posbyte) { for node in &node_route { @@ -1302,11 +1349,157 @@ impl LanguageServer for Backend { if new_name.is_empty() { return Err(tower_lsp::jsonrpc::Error { code: tower_lsp::jsonrpc::ErrorCode::InvalidParams, - message: "Note name cannot be empty".into(), + message: "Name cannot be empty".into(), data: None, }); } + // Check if we're renaming an anchor + let anchor_rename_result = || -> Option { + let repo_lock = self.repository.lock().unwrap(); + let repo = repo_lock.as_ref()?; + let ast = repo.ast_map.get(&uri)?; + let rope = repo.document_map.get(&uri)?; + + let line = rope.value().get_line(position.line as usize)?; + let line_str = line.as_str()?; + let posbyte = utf16_to_byte_idx(line_str, position.character as usize); + + // Check if cursor is on an anchor definition + let (old_anchor_name, anchor_loc) = + find_anchor_at_position(&ast, position.line as usize, posbyte)?; + + log::info!("Renaming anchor '{}' to '{}'", old_anchor_name, new_name); + + // Validate anchor name (similar rules to note names but allow # prefix) + let clean_new_name = new_name.trim_start_matches('#'); + if clean_new_name.is_empty() { + return None; + } + if clean_new_name.contains('/') + || clean_new_name.contains('\\') + || clean_new_name.contains('#') + { + return None; + } + + let mut document_changes = Vec::new(); + + // Get the current file's link name for finding references + let current_file_link = if let Ok(path) = uri.to_file_path() { + repo.path_to_link(&path)? + } else { + return None; + }; + + // 1. Update the anchor definition in the current file + // The anchor definition can be in two forms: + // - Short form: #anchor_name (span includes #) + // - Long form: {@anchor anchor_name} (span includes the whole expression) + let anchor_text = &line_str[anchor_loc.span.0..anchor_loc.span.1]; + let new_anchor_text = if anchor_text.starts_with("{@anchor") { + format!("{{@anchor {}}}", clean_new_name) + } else { + // Short form #anchor + format!("#{}", clean_new_name) + }; + + let start_char = utf16_from_byte_idx(line_str, anchor_loc.span.0) as u32; + let end_char = utf16_from_byte_idx(line_str, anchor_loc.span.1) as u32; + + let anchor_edit = TextEdit { + range: Range::new( + Position::new(anchor_loc.row as u32, start_char), + Position::new(anchor_loc.row as u32, end_char), + ), + new_text: new_anchor_text, + }; + + document_changes.push(DocumentChangeOperation::Edit(TextDocumentEdit { + text_document: OptionalVersionedTextDocumentIdentifier { + uri: uri.clone(), + version: None, + }, + edits: vec![OneOf::Left(anchor_edit)], + })); + + // 2. Find all links in the repository that reference this file with this anchor + if let Ok(graph) = repo.document_graph.lock() { + if let Some(target_node) = graph.get(&uri) { + // Iterate through all incoming edges (links pointing to this file) + for edge in target_node.iter_in() { + let source_uri = edge.source().key(); + let edge_data = edge.value(); + + // Get source document rope for line access + let source_rope = repo.document_map.get(source_uri)?; + + let mut edits = Vec::new(); + + // Create TextEdit for each link location that references this anchor + for link_loc in &edge_data.locations { + if link_loc.target_anchor.as_ref() == Some(&old_anchor_name) { + if let Some(line) = + source_rope.value().get_line(link_loc.source_line) + { + if let Some(src_line_str) = line.as_str() { + // Build new link text with updated anchor + let new_link_text = + format!("[{}#{}]", current_file_link, clean_new_name); + + // Convert byte offsets to UTF-16 + let start_char = utf16_from_byte_idx( + src_line_str, + link_loc.source_col_range.0, + ) + as u32; + let end_char = utf16_from_byte_idx( + src_line_str, + link_loc.source_col_range.1, + ) + as u32; + + let range = Range::new( + Position::new(link_loc.source_line as u32, start_char), + Position::new(link_loc.source_line as u32, end_char), + ); + + edits.push(OneOf::Left(TextEdit { + range, + new_text: new_link_text, + })); + } + } + } + } + + if !edits.is_empty() { + document_changes.push(DocumentChangeOperation::Edit( + TextDocumentEdit { + text_document: OptionalVersionedTextDocumentIdentifier { + uri: source_uri.clone(), + version: None, + }, + edits, + }, + )); + } + } + } + } + + Some(WorkspaceEdit { + document_changes: Some(DocumentChanges::Operations(document_changes)), + ..Default::default() + }) + }(); + + // If anchor rename succeeded, return it + if anchor_rename_result.is_some() { + return Ok(anchor_rename_result); + } + + // Otherwise, try note renaming (existing logic) if new_name.contains('/') || new_name.contains('\\') { return Err(tower_lsp::jsonrpc::Error { code: tower_lsp::jsonrpc::ErrorCode::InvalidParams, diff --git a/src/diagnostic_translator.rs b/src/diagnostic_translator.rs index 5a270de..ca27ee7 100644 --- a/src/diagnostic_translator.rs +++ b/src/diagnostic_translator.rs @@ -328,7 +328,8 @@ fn is_property_rule(rule: Rule) -> bool { rule, Rule::expr_property | Rule::property_name - | Rule::property_arg + | Rule::property_positional_arg + | Rule::property_keyword_pair | Rule::property_keyword_arg | Rule::property_keyword_value | Rule::trailing_properties diff --git a/src/parser.rs b/src/parser.rs index ce8fa93..3d0ffa3 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -1293,6 +1293,17 @@ fn transform_mail_link<'a>( } } +/// Helper to parse deadline strings +fn parse_deadline(value: &str) -> Deadline { + if let Ok(datetime) = chrono::NaiveDateTime::parse_from_str(value, "%Y-%m-%dT%H:%M") { + Deadline::DateTime(datetime) + } else if let Ok(date) = chrono::NaiveDate::parse_from_str(value, "%Y-%m-%d") { + Deadline::Date(date) + } else { + Deadline::Uninterpretable(value.to_string()) + } +} + fn transform_property( pair: Pair, input: &str, @@ -1317,60 +1328,98 @@ fn transform_property( Rule::expr_property => { let mut inner = pair.into_inner(); let property_name = inner.next().unwrap().as_str(); - if property_name != "task" { - log::warn!("Unknown property: {}", property_name); - return None; - } - let mut status = TaskStatus::Todo; - let mut due = Deadline::Uninterpretable("".to_string()); - let mut current_key = ""; - for kv in inner { - match kv.as_rule() { - Rule::property_keyword_arg => { - current_key = kv.as_str(); + match property_name { + "anchor" => { + // Long form anchor: {@anchor name} + // Expect one positional argument (the anchor name) + let anchor_name = inner.next().map(|p| p.as_str().to_string()); + if let Some(name) = anchor_name { + Some(Property::Anchor { name, location }) + } else { + log::warn!("Anchor property missing name"); + None } - Rule::property_keyword_value => { - let value = kv.as_str(); - if current_key == "status" { - if value == "todo" { - status = TaskStatus::Todo; - } else if value == "doing" { - status = TaskStatus::Doing; - } else if value == "done" { - status = TaskStatus::Done; - } else { + } + "task" => { + // Task property: {@task status=todo due=2024-12-31} + let mut status = TaskStatus::Todo; + let mut due = Deadline::Uninterpretable("".to_string()); + let mut current_key = ""; + + for kv in inner { + match kv.as_rule() { + Rule::property_keyword_pair => { + // Parse key=value pair + let mut pair_inner = kv.into_inner(); + let key = pair_inner.next().unwrap().as_str(); + let value = pair_inner.next().unwrap().as_str(); + + if key == "status" { + status = match value { + "todo" => TaskStatus::Todo, + "doing" => TaskStatus::Doing, + "done" => TaskStatus::Done, + _ => { + log::warn!( + "Unknown task status: '{}', interpreted as 'todo'", + value + ); + TaskStatus::Todo + } + }; + } else if key == "due" { + due = parse_deadline(value); + } else { + log::warn!("Unknown task property key: {}", key); + } + } + Rule::property_keyword_arg => { + current_key = kv.as_str(); + } + Rule::property_keyword_value => { + let value = kv.as_str(); + if current_key == "status" { + status = match value { + "todo" => TaskStatus::Todo, + "doing" => TaskStatus::Doing, + "done" => TaskStatus::Done, + _ => { + log::warn!( + "Unknown task status: '{}', interpreted as 'todo'", + value + ); + TaskStatus::Todo + } + }; + } else if current_key == "due" { + due = parse_deadline(value); + } else { + log::warn!("Unknown task property value: {}", value); + } + } + Rule::property_positional_arg => { log::warn!( - "Unknown task status: '{}', interpreted as 'todo'", - value + "Unexpected positional arg in task property: {}", + kv.as_str() ); } - } else if current_key == "due" { - if let Ok(datetime) = - chrono::NaiveDateTime::parse_from_str(value, "%Y-%m-%dT%H:%M") - { - due = Deadline::DateTime(datetime); - } else if let Ok(date) = - chrono::NaiveDate::parse_from_str(value, "%Y-%m-%d") - { - due = Deadline::Date(date); - } else { - due = Deadline::Uninterpretable(value.to_string()); + _ => { + log::warn!("Unexpected rule in task property: {:?}", kv.as_rule()); } - } else { - log::warn!("Unknown property value: {}", value); } } - _ => { - unreachable!(); - } + Some(Property::Task { + status, + due, + location, + }) + } + _ => { + log::warn!("Unknown property: {}", property_name); + None } } - Some(Property::Task { - status, - due, - location, - }) } Rule::expr_task => { let mut inner = pair.into_inner(); @@ -1382,15 +1431,7 @@ fn transform_property( _ => unreachable!(), }; let due_str = inner.as_str(); - let due = if let Ok(datetime) = - chrono::NaiveDateTime::parse_from_str(due_str, "%Y-%m-%dT%H:%M") - { - Deadline::DateTime(datetime) - } else if let Ok(date) = chrono::NaiveDate::parse_from_str(due_str, "%Y-%m-%d") { - Deadline::Date(date) - } else { - Deadline::Uninterpretable(due_str.to_string()) - }; + let due = parse_deadline(due_str); Some(Property::Task { status, due, @@ -1691,6 +1732,69 @@ mod tests { Ok(()) } + #[test] + fn test_parse_anchor_long_form() -> Result<(), Box> { + let input = "{@anchor myanchor}"; + let mut parsed = PattoLineParser::parse(Rule::statement, input)?; + let (_nodes, props) = transform_statement(parsed.next().unwrap(), input, 0, 0); + + assert_eq!(props.len(), 1, "Should have one anchor property"); + if let Property::Anchor { + ref name, + ref location, + } = props[0] + { + assert_eq!(name, "myanchor"); + // The location should cover the entire {@anchor myanchor} span + assert_eq!(location.span.0, 0); + assert_eq!(location.span.1, input.len()); + } else { + panic!("Expected anchor property"); + } + Ok(()) + } + + #[test] + fn test_parse_anchor_long_form_trailing() -> Result<(), Box> { + let input = "Some text {@anchor section1}"; + let mut parsed = PattoLineParser::parse(Rule::statement, input)?; + let (nodes, props) = transform_statement(parsed.next().unwrap(), input, 0, 0); + + // Should have text node and anchor property + assert_eq!(nodes.len(), 1, "Should have one text node"); + assert_eq!(props.len(), 1, "Should have one anchor property"); + + if let Property::Anchor { ref name, .. } = props[0] { + assert_eq!(name, "section1"); + } else { + panic!("Expected anchor property"); + } + Ok(()) + } + + #[test] + fn test_parse_anchor_both_forms() -> Result<(), Box> { + // Test that both short and long forms work in trailing position + let input = "Text #short {@anchor long1}"; + let mut parsed = PattoLineParser::parse(Rule::statement, input)?; + let (_nodes, props) = transform_statement(parsed.next().unwrap(), input, 0, 0); + + assert_eq!(props.len(), 2, "Should have two anchor properties"); + + if let Property::Anchor { ref name, .. } = props[0] { + assert_eq!(name, "short"); + } else { + panic!("Expected short anchor property"); + } + + if let Property::Anchor { ref name, .. } = props[1] { + assert_eq!(name, "long1"); + } else { + panic!("Expected long anchor property"); + } + Ok(()) + } + #[test] fn test_parse_math() { let input = "[@math ]"; diff --git a/src/patto.pest b/src/patto.pest index 977f945..b507caf 100644 --- a/src/patto.pest +++ b/src/patto.pest @@ -95,16 +95,18 @@ expr_math_inline = ${ "[$" ~ WHITE_SPACE_INLINE* ~ math_inline ~ "$]" } math_inline = @{ math_inline_char* } math_inline_char = _{ !"$]" ~ ANY } -expr_property = { "{@" ~ property_name ~ (WHITE_SPACE_INLINE+ ~ property_keyword_arg ~ "=" ~ property_keyword_value)* ~ "}" } -//expr_property = ${ "{@" ~ property_name ~ ( WHITE_SPACE_INLINE+ ~ _property_arg )* ~ "}" } +// Property syntax: {@name arg1 key=value ...} +// Supports both positional args and keyword args +expr_property = { "{@" ~ property_name ~ (WHITE_SPACE_INLINE+ ~ (property_keyword_pair | property_positional_arg))* ~ "}" } property_name = @{ ASCII_ALPHANUMERIC+ } -property_arg = @{ ASCII_ALPHANUMERIC+ } +property_keyword_pair = ${ property_keyword_arg ~ "=" ~ property_keyword_value } +property_positional_arg = @{ (ASCII_ALPHANUMERIC|CJK|"_"|"-")+ } property_keyword_arg = @{ ASCII_ALPHANUMERIC+ } property_keyword_value = @{ (ASCII_ALPHANUMERIC|CJK|"-"|"/"|":"|"_")+ } trailing_properties = ${ (WHITE_SPACE_INLINE+ ~ (expr_property | expr_anchor | expr_task))+ } // ignore white spaces expr_anchor = ${ "#" ~ anchor } -anchor = @{ (ASCII_ALPHANUMERIC|CJK|"_"|"-")+ } +anchor = @{ (!(WHITE_SPACE_INLINE | "}" | "]" | "\r" | "\n") ~ ANY)+ } expr_task = ${ (symbol_task_done | symbol_task_doing | symbol_task_todo ) ~ task_due } symbol_task_done = @{"-"} symbol_task_doing = @{"*"} diff --git a/src/repository.rs b/src/repository.rs index bd79003..47dc93c 100644 --- a/src/repository.rs +++ b/src/repository.rs @@ -189,7 +189,8 @@ impl Repository { ) .as_str(), ); - Some(Self::normalize_url_percent_encoding(&linkuri)) + let normalized = Self::normalize_url_percent_encoding(&linkuri); + Some(normalized) } else { None } diff --git a/tests/common/assertions.rs b/tests/common/assertions.rs index 465f509..6674611 100644 --- a/tests/common/assertions.rs +++ b/tests/common/assertions.rs @@ -6,7 +6,9 @@ pub fn assert_has_text_edit(changes: &Value, file_name: &str, expected_text: &st for change in array { if let Some(text_doc) = change.get("textDocument") { if let Some(uri) = text_doc.get("uri").and_then(|v| v.as_str()) { - if uri.contains(file_name) { + let decoded_uri = + urlencoding::decode(uri).unwrap_or(std::borrow::Cow::Borrowed(uri)); + if decoded_uri.contains(file_name) { if let Some(edits) = change.get("edits").and_then(|v| v.as_array()) { for edit in edits { if let Some(new_text) = edit.get("newText").and_then(|v| v.as_str()) @@ -30,8 +32,13 @@ pub fn assert_has_file_rename(changes: &Value, old_name: &str, new_name: &str) - if let Some(array) = changes.as_array() { for change in array { if change.get("kind").and_then(|v| v.as_str()) == Some("rename") { - let old_uri = change.get("oldUri").and_then(|v| v.as_str()).unwrap_or(""); - let new_uri = change.get("newUri").and_then(|v| v.as_str()).unwrap_or(""); + let old_uri_raw = change.get("oldUri").and_then(|v| v.as_str()).unwrap_or(""); + let new_uri_raw = change.get("newUri").and_then(|v| v.as_str()).unwrap_or(""); + + let old_uri = urlencoding::decode(old_uri_raw) + .unwrap_or(std::borrow::Cow::Borrowed(old_uri_raw)); + let new_uri = urlencoding::decode(new_uri_raw) + .unwrap_or(std::borrow::Cow::Borrowed(new_uri_raw)); if old_uri.contains(old_name) && new_uri.contains(new_name) { return true; diff --git a/tests/lsp_rename_anchors.rs b/tests/lsp_rename_anchors.rs index 9c5b756..45097b1 100644 --- a/tests/lsp_rename_anchors.rs +++ b/tests/lsp_rename_anchors.rs @@ -233,3 +233,649 @@ async fn test_different_anchors_in_same_file() { println!("✅ Different anchors in same file test passed"); } + +// ========================================== +// Tests for Anchor Renaming (renaming anchor definitions) +// ========================================== + +#[tokio::test] +async fn test_prepare_rename_on_anchor_definition() { + let mut workspace = TestWorkspace::new(); + workspace.create_file("note_a.pn", "Content here\n#section1\nMore content\n"); + + let mut client = LspTestClient::new(&workspace).await; + client.initialize().await; + client.initialized().await; + + let uri = workspace.get_uri("note_a.pn"); + client + .did_open( + uri.clone(), + "Content here\n#section1\nMore content\n".to_string(), + ) + .await; + + // Position cursor on #section1 (line 1, character 1 which is inside #section1) + let response = client.prepare_rename(uri, 1, 1).await; + + assert!(response.get("result").is_some(), "prepare_rename failed"); + assert!( + response["result"]["range"].is_object(), + "No range in prepare_rename" + ); + assert_eq!( + response["result"]["placeholder"].as_str(), + Some("section1"), + "Wrong placeholder for anchor" + ); + + println!("✅ Prepare rename on anchor definition test passed"); +} + +#[tokio::test] +async fn test_prepare_rename_on_anchor_long_form() { + let mut workspace = TestWorkspace::new(); + workspace.create_file( + "note_a.pn", + "Content here\n{@anchor section1}\nMore content\n", + ); + + let mut client = LspTestClient::new(&workspace).await; + client.initialize().await; + client.initialized().await; + + let uri = workspace.get_uri("note_a.pn"); + client + .did_open( + uri.clone(), + "Content here\n{@anchor section1}\nMore content\n".to_string(), + ) + .await; + + // Position cursor on {@anchor section1} (line 1, character 10 which is inside "section1") + let response = client.prepare_rename(uri, 1, 10).await; + + assert!( + response.get("result").is_some(), + "prepare_rename failed for long form anchor" + ); + assert!( + response["result"]["range"].is_object(), + "No range in prepare_rename for long form" + ); + assert_eq!( + response["result"]["placeholder"].as_str(), + Some("section1"), + "Wrong placeholder for long form anchor" + ); + + println!("✅ Prepare rename on long form anchor definition test passed"); +} + +#[tokio::test] +async fn test_rename_anchor_simple() { + let mut workspace = TestWorkspace::new(); + workspace.create_file("note_a.pn", "Content\n#old_anchor\nMore content\n"); + workspace.create_file("note_b.pn", "Link to [note_a#old_anchor]\n"); + + let mut client = LspTestClient::new(&workspace).await; + client.initialize().await; + client.initialized().await; + + let uri_a = workspace.get_uri("note_a.pn"); + let uri_b = workspace.get_uri("note_b.pn"); + + client + .did_open( + uri_a.clone(), + "Content\n#old_anchor\nMore content\n".to_string(), + ) + .await; + client + .did_open(uri_b.clone(), "Link to [note_a#old_anchor]\n".to_string()) + .await; + + // Wait for workspace scan + tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; + + // Rename anchor: position on #old_anchor (line 1, char 1) + let response = client.rename(uri_a, 1, 1, "new_anchor").await; + + assert!( + response.get("result").is_some(), + "Rename failed: {:?}", + response + ); + let doc_changes = &response["result"]["documentChanges"]; + + // Verify anchor definition is updated in note_a.pn + assert!( + assert_has_text_edit(doc_changes, "note_a.pn", "#new_anchor"), + "Anchor definition not updated" + ); + + // Verify link is updated in note_b.pn + assert!( + assert_has_text_edit(doc_changes, "note_b.pn", "[note_a#new_anchor]"), + "Link with anchor not updated" + ); + + println!("✅ Simple anchor rename test passed"); +} + +#[tokio::test] +async fn test_rename_anchor_long_form() { + // Test the long form {@anchor name} syntax + let mut workspace = TestWorkspace::new(); + workspace.create_file("note_a.pn", "Content\n{@anchor old_anchor}\nMore content\n"); + workspace.create_file("note_b.pn", "Link to [note_a#old_anchor]\n"); + + let mut client = LspTestClient::new(&workspace).await; + client.initialize().await; + client.initialized().await; + + let uri_a = workspace.get_uri("note_a.pn"); + let uri_b = workspace.get_uri("note_b.pn"); + + client + .did_open( + uri_a.clone(), + "Content\n{@anchor old_anchor}\nMore content\n".to_string(), + ) + .await; + client + .did_open(uri_b.clone(), "Link to [note_a#old_anchor]\n".to_string()) + .await; + + // Wait for workspace scan + tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; + + // Rename anchor: position inside {@anchor old_anchor} (line 1, char 10 which is inside "old_anchor") + let response = client.rename(uri_a, 1, 10, "new_anchor").await; + + assert!( + response.get("result").is_some(), + "Rename failed: {:?}", + response + ); + let doc_changes = &response["result"]["documentChanges"]; + + // Verify anchor definition is updated (should preserve long form) + assert!( + assert_has_text_edit(doc_changes, "note_a.pn", "{@anchor new_anchor}"), + "Long form anchor definition not updated" + ); + + // Verify link is updated in note_b.pn + assert!( + assert_has_text_edit(doc_changes, "note_b.pn", "[note_a#new_anchor]"), + "Link with anchor not updated" + ); + + println!("✅ Long form anchor rename test passed"); +} + +#[tokio::test] +async fn test_rename_anchor_multiple_references() { + let mut workspace = TestWorkspace::new(); + workspace.create_file("target.pn", "Content\n#myanchor\nMore content\n"); + workspace.create_file("ref1.pn", "See [target#myanchor] for details\n"); + workspace.create_file( + "ref2.pn", + "Also [target#myanchor] and [target#myanchor] again\n", + ); + workspace.create_file("ref3.pn", "Just [target] no anchor\n"); + + let mut client = LspTestClient::new(&workspace).await; + client.initialize().await; + client.initialized().await; + + let uri_target = workspace.get_uri("target.pn"); + + client + .did_open( + uri_target.clone(), + "Content\n#myanchor\nMore content\n".to_string(), + ) + .await; + + // Wait for workspace scan + tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; + + // Rename anchor + let response = client.rename(uri_target, 1, 1, "renamed_anchor").await; + + assert!( + response.get("result").is_some(), + "Rename failed: {:?}", + response + ); + let doc_changes = &response["result"]["documentChanges"]; + + // Verify anchor definition updated + assert!( + assert_has_text_edit(doc_changes, "target.pn", "#renamed_anchor"), + "Anchor definition not updated" + ); + + // Verify ref1.pn updated + assert!( + assert_has_text_edit(doc_changes, "ref1.pn", "[target#renamed_anchor]"), + "ref1.pn link not updated" + ); + + // Verify ref2.pn updated (both occurrences) + assert!( + assert_has_text_edit(doc_changes, "ref2.pn", "[target#renamed_anchor]"), + "ref2.pn links not updated" + ); + + // Verify ref3.pn is NOT in the changes (no anchor reference) + let changes = doc_changes.as_array().unwrap(); + let ref3_changed = changes.iter().any(|change| { + change + .get("textDocument") + .and_then(|td| td.get("uri")) + .and_then(|u| u.as_str()) + .map(|s| s.contains("ref3.pn")) + .unwrap_or(false) + }); + assert!( + !ref3_changed, + "ref3.pn should not be modified (has no anchor reference)" + ); + + println!("✅ Multiple references anchor rename test passed"); +} + +#[tokio::test] +async fn test_rename_anchor_does_not_affect_other_anchors() { + let mut workspace = TestWorkspace::new(); + workspace.create_file("target.pn", "#anchor1\nContent\n#anchor2\n"); + workspace.create_file("ref.pn", "[target#anchor1]\n[target#anchor2]\n"); + + let mut client = LspTestClient::new(&workspace).await; + client.initialize().await; + client.initialized().await; + + let uri_target = workspace.get_uri("target.pn"); + let uri_ref = workspace.get_uri("ref.pn"); + + client + .did_open( + uri_target.clone(), + "#anchor1\nContent\n#anchor2\n".to_string(), + ) + .await; + client + .did_open( + uri_ref.clone(), + "[target#anchor1]\n[target#anchor2]\n".to_string(), + ) + .await; + + // Wait for workspace scan + tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; + + // Rename only anchor1 (line 0, char 1) + let response = client.rename(uri_target, 0, 1, "new_anchor1").await; + + assert!( + response.get("result").is_some(), + "Rename failed: {:?}", + response + ); + let doc_changes = &response["result"]["documentChanges"]; + + // Verify anchor1 is updated + assert!( + assert_has_text_edit(doc_changes, "target.pn", "#new_anchor1"), + "anchor1 not updated" + ); + + // Verify link to anchor1 is updated + assert!( + assert_has_text_edit(doc_changes, "ref.pn", "[target#new_anchor1]"), + "Link to anchor1 not updated" + ); + + // Verify anchor2 and its reference are NOT changed + // Check that no edit contains "anchor2" being changed + let changes = doc_changes.as_array().unwrap(); + for change in changes { + if let Some(edits) = change.get("edits").and_then(|e| e.as_array()) { + for edit in edits { + if let Some(new_text) = edit.get("newText").and_then(|t| t.as_str()) { + assert!( + !new_text.contains("anchor2") || new_text == "[target#anchor2]", + "anchor2 should not be modified: found '{}'", + new_text + ); + } + } + } + } + + println!("✅ Anchor rename isolation test passed"); +} + +#[tokio::test] +async fn test_multibyte_note_and_anchor_rename() { + let mut workspace = TestWorkspace::new(); + workspace.create_file("リンク元.pn", "Link to [ノート#セクション]\n"); + workspace.create_file("ノート.pn", "Content\n#セクション\n"); + + let mut client = LspTestClient::new(&workspace).await; + client.initialize().await; + client.initialized().await; + + let uri_src = workspace.get_uri("リンク元.pn"); + let uri_note = workspace.get_uri("ノート.pn"); + + client + .did_open(uri_src.clone(), "Link to [ノート#セクション]\n".to_string()) + .await; + client + .did_open(uri_note.clone(), "Content\n#セクション\n".to_string()) + .await; + + // Wait for workspace scan + tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; + + // 1. Rename Note "ノート" -> "メモ" (from reference in リンク元.pn) + // "Link to [ノート#セクション]" + // "Link to [" is 9 characters (ASCII) + let response = client.rename(uri_src.clone(), 0, 9, "メモ").await; + + assert!(!response["result"].is_null(), "Note rename result is null"); + assert!( + response.get("result").is_some(), + "Note rename failed: {:?}", + response + ); + let doc_changes = &response["result"]["documentChanges"]; + + // Verify file rename + assert!( + assert_has_file_rename(doc_changes, "ノート.pn", "メモ.pn"), + "File rename not found in changes" + ); + + // Verify reference update + assert!( + assert_has_text_edit(doc_changes, "リンク元.pn", "[メモ#セクション]"), + "Link to multi-byte note not updated" + ); + + // 2. Rename Anchor "#セクション" -> "#部分" (from definition in ノート.pn) + // "Content\n#セクション\n" -> Line 1, char 1 (after '#') + let response = client.rename(uri_note.clone(), 1, 1, "部分").await; + + assert!( + !response["result"].is_null(), + "Anchor rename result is null" + ); + assert!( + response.get("result").is_some(), + "Anchor rename failed: {:?}", + response + ); + let doc_changes = &response["result"]["documentChanges"]; + + // Verify definition update + assert!( + assert_has_text_edit(doc_changes, "ノート.pn", "#部分"), + "Multi-byte anchor definition not updated" + ); + + // Verify reference update (assuming starting state since we didn't apply previous edits) + // The reference in 'リンク元.pn' is '[ノート#セクション]' + assert!( + assert_has_text_edit(doc_changes, "リンク元.pn", "[ノート#部分]"), + "Link to multi-byte anchor not updated: {:?}", + doc_changes + ); + + println!("✅ Multi-byte note and anchor rename test passed"); +} + +#[tokio::test] +async fn test_multibyte_long_rename() { + let mut workspace = TestWorkspace::new(); + let note_name = "長い日本語のファイル名_1234567890"; + let anchor_name = "長いアンカー名_section_with_emoji_🧩"; + + // Note A refers to Note B with anchor + let content_a = format!("Link to [{note_name}#{anchor_name}]\n"); + workspace.create_file("source.pn", &content_a); + + // Note B definition + let content_b = format!("Content\n#{anchor_name}\nMore Content\n"); + let note_path = workspace.create_file(&format!("{}.pn", note_name), &content_b); + + assert!(note_path.exists(), "Note file was not created"); + + let mut client = LspTestClient::new(&workspace).await; + client.initialize().await; + client.initialized().await; + + let uri_src = workspace.get_uri("source.pn"); + let uri_note = workspace.get_uri(&format!("{}.pn", note_name)); + + client.did_open(uri_src.clone(), content_a.clone()).await; + client.did_open(uri_note.clone(), content_b.clone()).await; + + // Wait for workspace scan + tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await; + + // 1. Rename Note from "source.pn" + // "Link to [" is 9 bytes/chars (ASCII) + // Cursor at 10 (first char of name) to be safe. + let new_note_name = "さらに長い新しい日本語のファイル名_modified_🚀"; + + let response = client.rename(uri_src.clone(), 0, 10, new_note_name).await; + + assert!( + !response["result"].is_null(), + "Long note rename result is null" + ); + assert!( + response.get("result").is_some(), + "Long note rename failed: {:?}", + response + ); + let doc_changes = &response["result"]["documentChanges"]; + + // Verify file rename + assert!( + assert_has_file_rename( + doc_changes, + &format!("{}.pn", note_name), + &format!("{}.pn", new_note_name) + ), + "File rename not found for long note name" + ); + + // Verify reference update + assert!( + assert_has_text_edit( + doc_changes, + "source.pn", + &format!("[{new_note_name}#{anchor_name}]") + ), + "Link to long note not updated correctly" + ); + + // 2. Rename Anchor must track the rename conceptually but here we operate on old state unless we apply edits. + let new_anchor_name = "変更されたアンカー_truncated"; + let response = client.rename(uri_note.clone(), 1, 1, new_anchor_name).await; + + assert!( + !response["result"].is_null(), + "Long anchor rename result is null" + ); + let doc_changes = &response["result"]["documentChanges"]; + + // Verify definition update + assert!( + assert_has_text_edit( + doc_changes, + &format!("{}.pn", note_name), + &format!("#{new_anchor_name}") + ), + "Long anchor definition not updated" + ); + + // Verify reference update + assert!( + assert_has_text_edit( + doc_changes, + "source.pn", + &format!("[{note_name}#{new_anchor_name}]") + ), + "Link to long anchor not updated" + ); + + println!("✅ Long multi-byte note and anchor rename test passed"); +} + +#[tokio::test] +async fn test_multiline_content_rename() { + let mut workspace = TestWorkspace::new(); + + // Create a note with multiple lines and sections + let note_name = "multiline_note"; + let anchor_name = "target_section"; + let mut note_content = String::new(); + note_content.push_str("# Multiline Note Title\n\n"); + for i in 1..15 { + note_content.push_str(&format!("This is line {} of padding text.\n", i)); + } + note_content.push_str(&format!("#{}\n", anchor_name)); + note_content.push_str("Content of the target section.\n"); + for i in 16..30 { + note_content.push_str(&format!("This is footer line {}.\n", i)); + } + + workspace.create_file(&format!("{}.pn", note_name), ¬e_content); + + // Create source file ensuring link is in the middle + let mut src_content = String::new(); + src_content.push_str("# Source Content\n"); + for i in 1..10 { + src_content.push_str(&format!("Source padding line {}.\n", i)); + } + // Line 10 (0-indexed) will be the link + // "Check " is 6 chars. `[` is at 6. Name starts at 7. + let link_line_prefix = "Check "; + src_content.push_str(&format!( + "Check [{}#{}] for details.\n", + note_name, anchor_name + )); + + for i in 11..20 { + src_content.push_str(&format!("Source footer line {}.\n", i)); + } + + workspace.create_file("source.pn", &src_content); + + let mut client = LspTestClient::new(&workspace).await; + client.initialize().await; + client.initialized().await; + + let uri_src = workspace.get_uri("source.pn"); + client.did_open(uri_src.clone(), src_content).await; + + // Wait for scanning + tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await; + + // 1. Rename the Note + // Cursor position: Line 10 (0-indexed). + // Intro lines: Heading(1) + 9 lines (1..10) = 10 lines. Indices 0..9. + // So link is at line 10 (index 10). + // Col: "Check " length is 6. `[` is 6. Name starts at 7. + let rename_col = link_line_prefix.len() + 1; // 7 + let new_note_name = "renamed_multiline_note"; + + let response = client + .rename(uri_src.clone(), 10, rename_col as u32, new_note_name) + .await; + + assert!(!response["result"].is_null(), "Note rename result is null"); + let doc_changes = &response["result"]["documentChanges"]; + + // Verify file rename + assert!( + assert_has_file_rename( + doc_changes, + &format!("{}.pn", note_name), + &format!("{}.pn", new_note_name) + ), + "File rename failed" + ); + + // Verify text edit in source + assert!( + assert_has_text_edit( + doc_changes, + "source.pn", + &format!("[{new_note_name}#{anchor_name}]") + ), + "Link text update failed for note rename" + ); + + // 2. Rename the Anchor + let new_anchor_name = "renamed_section"; + // Renaming anchor from reference is not supported by the server yet. + // We must rename from the definition. + + // Open the note file + let uri_note = workspace.get_uri(&format!("{}.pn", note_name)); + client.did_open(uri_note.clone(), note_content).await; + + // Anchor definition is at line 16. + // "#target_section" + // # is 0. Name starts at 1. + let anchor_def_line = 16; + let anchor_def_col = 1; + + // Wait a bit to ensure note is processed (though did_open should be fast) + tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; + + let response_anchor = client + .rename( + uri_note.clone(), + anchor_def_line, + anchor_def_col, + new_anchor_name, + ) + .await; + + assert!( + !response_anchor["result"].is_null(), + "Anchor rename result is null" + ); + let doc_changes_anchor = &response_anchor["result"]["documentChanges"]; + + // Verify definition update in note file + assert!( + assert_has_text_edit( + doc_changes_anchor, + &format!("{}.pn", note_name), + &format!("#{new_anchor_name}") + ), + "Anchor definition update failed" + ); + + // Verify usage update in source file + assert!( + assert_has_text_edit( + doc_changes_anchor, + "source.pn", + &format!("[{note_name}#{new_anchor_name}]") + ), + "Anchor usage update failed" + ); + + println!("✅ Multiline content rename test passed"); +} diff --git a/todo.md b/todo.md index e50c0c5..01e7fdb 100644 --- a/todo.md +++ b/todo.md @@ -11,6 +11,8 @@ * we do not support `#` for the name of notes. * eliminate the logic that self-link if link is empty * better depth and state handling + * [ ] custom macro command + * plugin system * LSP server * [x] async note scanning * [x] return all errors as diagnostics @@ -33,7 +35,7 @@ * vim-lsp does not support CreateFile/RenameFile/DeleteFile * [https://github.com/prabirshrestha/vim-lsp/issues/1371](https://github.com/prabirshrestha/vim-lsp/issues/1371) * yegappan/lsp supports these - * [ ] anchor renaming + * [x] anchor renaming * [x] make error.variant.message() user-friendly * [x] fix indentation error at a line after a block with trailing empty lines * [x] lsp server hangs sometimes