Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions crates/tracedecay-source-edit/src/edits/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ mod symbols;
#[cfg(test)]
mod execute_tests;
#[cfg(test)]
mod move_destination_tests;
#[cfg(test)]
mod reconcile_tests;
#[cfg(test)]
mod test_support;
Expand Down
268 changes: 268 additions & 0 deletions crates/tracedecay-source-edit/src/edits/move_destination_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
use std::fs;
use std::path::Path;
use std::process::Command;

use tempfile::tempdir;
use tracedecay_contracts::{
EffectTermination, SourceEditKind, SourceEditRequest, source_edit_operation,
};

use super::test_support::{
fixture_authorization, fixture_graph, fixture_request_for_edit, fixture_symbol_code_graph,
};
use crate::execute::resolve_source_edit_preview;
use crate::{SourceEditOutcome, execute_source_edit, preview_source_edit_expected_state};

fn compile_move_destination_fixture(project: &Path) {
let output = Command::new(std::env::var_os("RUSTC").unwrap_or_else(|| "rustc".into()))
.current_dir(project)
.args([
"--edition=2024",
"--crate-type=lib",
"--emit=metadata",
"--crate-name=move_destination",
"src/lib.rs",
"-o",
"fixture.rmeta",
])
.output()
.unwrap();
assert!(
output.status.success(),
"move destination must compile: {}",
String::from_utf8_lossy(&output.stderr)
);
}

async fn move_with_destination_header(
before_import: &str,
after_import: &str,
newline: &str,
destination_exists: bool,
) {
let project = tempdir().unwrap();
fs::create_dir(project.path().join("src")).unwrap();
let moved_text = "pub fn moved(value: Widget) -> Widget {\n value\n}";
let source = format!("use crate::model::Widget;\n\n{moved_text}\n");
let destination = format!("{before_import}{after_import}");
fs::write(project.path().join("src/source.rs"), &source).unwrap();
fs::write(project.path().join("src/model.rs"), "pub struct Widget;\n").unwrap();
fs::write(
project.path().join("src/lib.rs"),
"#![allow(dead_code, unused_imports)]\nmod model;\nmod source;\nmod relocated;\n",
)
.unwrap();
if destination_exists {
fs::write(project.path().join("src/relocated.rs"), &destination).unwrap();
compile_move_destination_fixture(project.path());
}
let (graph, _) = fixture_graph(project.path()).await;
let code_graph = fixture_symbol_code_graph(
"src/source.rs",
&source,
moved_text,
"moved",
"source::moved",
);
let mut request = fixture_request_for_edit(
SourceEditRequest::MoveSymbol {
symbol: "source::moved".to_owned(),
dest_file: "src/relocated.rs".to_owned(),
dry_run: false,
update_references: false,
},
"source-edit.move-destination",
);
let preview = resolve_source_edit_preview(
&graph,
&code_graph,
&request.context,
request.observed_at,
tracedecay_graph_query::request_graph_cancellation(&request.context),
request.edit.clone(),
)
.await
.unwrap();
let SourceEditOutcome::Move(result) = &preview.outcome else {
panic!("expected a move preview");
};
assert!(result.success, "{}", result.message);
assert_eq!(result.applied_imports, ["use crate::model::Widget;"]);
assert_eq!(preview.planned_files.len(), 2);
assert_eq!(
fs::read_to_string(project.path().join("src/source.rs")).unwrap(),
source
);
if destination_exists {
assert_eq!(
fs::read_to_string(project.path().join("src/relocated.rs")).unwrap(),
destination
);
} else {
assert!(!project.path().join("src/relocated.rs").exists());
}
request.expected_state = preview.expected_state.unwrap();
let authorization = fixture_authorization(&request);
let applied = execute_source_edit(
&graph,
&code_graph,
&source_edit_operation(SourceEditKind::MoveSymbol).unwrap(),
request,
&authorization,
)
.await
.unwrap();
assert!(applied.outcome.success(), "{}", applied.outcome.message());
for planned in preview.planned_files {
assert_eq!(
fs::read_to_string(project.path().join(&planned.relative_path)).unwrap(),
planned.intended,
"apply must publish the exact preview for {}",
planned.relative_path
);
}
compile_move_destination_fixture(project.path());
let actual = fs::read_to_string(project.path().join("src/relocated.rs")).unwrap();
let unchanged_destination =
format!("{before_import}use crate::model::Widget;{newline}{after_import}");
assert!(actual.starts_with(&unchanged_destination), "{actual:?}");
assert!(actual.ends_with(&format!("{moved_text}\n")));
let mut parser = tree_sitter::Parser::new();
let language = tracedecay_code_extraction::ts_provider::try_language("rust").unwrap();
parser.set_language(&language).unwrap();
let tree = parser.parse(&actual, None).unwrap();
assert!(!tree.root_node().has_error());
assert!(
tree.root_node()
.named_children(&mut tree.walk())
.any(|node| {
node.kind() == "use_declaration"
&& node.utf8_text(actual.as_bytes()).unwrap() == "use crate::model::Widget;"
}),
"the dependency must be at the destination module root"
);
}

#[tokio::test]
async fn move_symbol_imports_preserve_destination_syntax_in_preview_and_apply() {
for (before, after) in [
(
"use std::{\n fmt::{self, Debug as DebugTrait},\n collections::HashMap,\n};\n",
"pub fn marker() {}\n",
),
(
"//! Destination module.\n#![allow(dead_code)]\n/*! Inner block docs. */\n#![allow(unused_imports)]\nuse std::fmt::Debug;\n",
"pub fn marker() {}\n",
),
(
"// Header.\nuse std::fmt::Debug; // Existing import.\n\n",
"/// Marker documentation.\n#[inline]\npub fn marker() {}\n",
),
(
"",
"#[cfg(any())]\nuse missing_dependency::{Missing, AlsoMissing};\n/** Marker docs. */\n#[inline]\npub fn marker() {}\n",
),
(
"",
"mod nested {\n use std::{fmt::Debug, collections::HashMap};\n}\npub fn marker() {}\n",
),
(
"\u{feff}#!/usr/bin/env rust-script\n//! Destination module.\n",
"pub fn marker() {}\n",
),
] {
move_with_destination_header(before, after, "\n", true).await;
}
}

#[tokio::test]
async fn move_symbol_preserves_destination_bytes_when_adding_imports() {
move_with_destination_header(
"//! Module ☃.\r\nuse std::fmt::Debug;\r\n\r\n",
"/// Marker.\r\n#[inline]\r\npub fn marker() {}\r\n \t\r\n\r\n",
"\r\n",
true,
)
.await;
move_with_destination_header("\r\n \t\r\n", "", "\r\n", true).await;
move_with_destination_header("", "", "\n", true).await;
move_with_destination_header("", "", "\n", false).await;
}

#[tokio::test]
async fn move_symbol_refuses_unparseable_destination_before_preview_or_apply() {
for (destination, dependency) in [
("use std::{\n", true),
("pub fn marker() {\n", false),
("/* unclosed destination comment", true),
("/// Detached documentation.\n#[inline]\n", false),
] {
let project = tempdir().unwrap();
fs::create_dir(project.path().join("src")).unwrap();
let moved_text = if dependency {
"pub fn moved(value: Widget) -> Widget { value }"
} else {
"pub fn moved() {}"
};
let source = format!("use crate::model::Widget;\n\n{moved_text}\n");
fs::write(project.path().join("src/source.rs"), &source).unwrap();
fs::write(project.path().join("src/relocated.rs"), destination).unwrap();
let (graph, _) = fixture_graph(project.path()).await;
let code_graph = fixture_symbol_code_graph(
"src/source.rs",
&source,
moved_text,
"moved",
"source::moved",
);
let request = fixture_request_for_edit(
SourceEditRequest::MoveSymbol {
symbol: "source::moved".to_owned(),
dest_file: "src/relocated.rs".to_owned(),
dry_run: false,
update_references: false,
},
"source-edit.move-invalid-destination",
);
let preview = preview_source_edit_expected_state(
&graph,
&code_graph,
&request.context,
request.observed_at,
request.edit.clone(),
)
.await;
assert!(
preview.is_err(),
"malformed destination must not get an accepted preview"
);
let authorization = fixture_authorization(&request);
let applied = execute_source_edit(
&graph,
&code_graph,
&source_edit_operation(SourceEditKind::MoveSymbol).unwrap(),
request,
&authorization,
)
.await
.unwrap();
assert!(!applied.outcome.success());
assert_eq!(
applied.effect.unwrap().receipt.outcome,
EffectTermination::Failed
);
assert_eq!(
fs::read_to_string(project.path().join("src/source.rs")).unwrap(),
source
);
assert_eq!(
fs::read_to_string(project.path().join("src/relocated.rs")).unwrap(),
destination
);
assert!(
!crate::journal::SourceEditDurability::for_graph(&graph)
.journal_path()
.exists()
);
}
}
11 changes: 5 additions & 6 deletions crates/tracedecay-source-edit/src/edits/preview.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
//! Dry-run preview rendering shared by every edit primitive: a bounded
//! single-hunk diff of the changed region, the success-message wrapper that
//! marks dry runs, and the line classifier shared by `move_symbol`'s header
//! scanner and inner-doc skip loop.
//! marks dry runs, and the line classifier used by `move_symbol`'s source
//! inner-doc skip loop.

/// What a single source line looks like when scanning a file's leading
/// region. One classifier shared by every place that used to hand-roll its
/// own prefix checks in `move_symbol`'s import-insertion header scanner (which
/// leading lines belong to the header vs. the first item) and its `//!` skip
/// loop (never let a moved span start on an inner module doc).
/// region. MoveSymbol's source-span `//!` skip loop uses this classifier to
/// keep an inner module doc out of the moved item. Destination insertion uses
/// Rust syntax and byte ranges instead.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum LeadingKind {
/// Empty (after trimming leading whitespace).
Expand Down
3 changes: 2 additions & 1 deletion crates/tracedecay-source-edit/src/move_symbol/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,8 @@ pub(crate) async fn move_symbol(
&dest_original,
dest_module.as_deref(),
);
let dest_modified = build_dest_content(&dest_original, &applied_imports, &moved_text);
let dest_modified =
build_dest_content(&dest_rel, &dest_original, &applied_imports, &moved_text)?;

let mut impact = analysis.hints;
impact.extend(
Expand Down
Loading
Loading