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
4 changes: 3 additions & 1 deletion crates/wright-analyzer/src/canonical/cfg.rs
Original file line number Diff line number Diff line change
Expand Up @@ -252,8 +252,10 @@ fn action_subroutine(program: &Program, action: &Action) -> Option<usize> {
Action::Call { name, .. } => name,
_ => return None,
};
// Name resolution mirrors workshop-rs: a redeclared name binds to the
// last declaration.
program
.subroutines
.iter()
.position(|subroutine| subroutine.name == *name)
.rposition(|subroutine| subroutine.name == *name)
}
16 changes: 15 additions & 1 deletion crates/wright-analyzer/src/canonical/symbols.rs
Original file line number Diff line number Diff line change
Expand Up @@ -374,10 +374,12 @@ impl SemanticIndex {
})
}));
}
/// Name resolution mirrors workshop-rs: a name that declares more than
/// once binds its references to the last declaration.
fn find_symbol(&self, kind: SymbolKind, name: &str) -> Option<SymbolId> {
self.symbols
.iter()
.find(|symbol| symbol.kind == kind && symbol.name == name)
.rfind(|symbol| symbol.kind == kind && symbol.name == name)
.map(|s| s.id)
}
fn walk_event(&mut self, event: &Event, rule: RuleId, program: &Program) {
Expand Down Expand Up @@ -514,6 +516,18 @@ impl SemanticIndex {
);
}
}
Value::Subroutine(name) => {
if let Some(symbol) = self.find_symbol(SymbolKind::Subroutine, name) {
self.push(
symbol,
ReferenceKind::Call,
Some(rule),
action,
Some(value_id),
span,
);
}
}
_ => {}
}
value_id
Expand Down
28 changes: 26 additions & 2 deletions crates/wright-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,9 @@ pub(crate) struct Cli {

pub(crate) const LONG_ABOUT: &str = "Wright compiler and Workshop tooling CLI.

Commands check correctness, summarize semantic hotspots, lint, compile, or
reconstruct source through the typed wright-driver result envelope. `inspect`
Commands check correctness, summarize semantic hotspots, lint, compile,
rename Workshop symbols, or reconstruct source through the typed
wright-driver result envelope. `inspect`
prints the semantic summary, and its query subcommands (symbols, refs, cfg,
callgraph, cost) expose each detail area. `compile` and `convert`
keep their source artifact stdout contracts; JSON mode prints only one
Expand Down Expand Up @@ -89,6 +90,9 @@ pub(crate) enum Command {
subcommand_precedence_over_arg = true
)]
Inspect(InspectArgs),
/// Rename a Workshop variable or subroutine semantically (#434): previews
/// the validated source diff by default; `--write` applies it atomically.
Rename(RenameArgs),
/// Generate static shell completion from the command model.
Completion(CompletionArgs),
/// Update Wright-managed components: a standalone installation and
Expand Down Expand Up @@ -191,6 +195,26 @@ pub(crate) struct CommonArgs {
pub(crate) color: ColorArg,
}

/// Arguments of `rename` (#434): the declared symbol name and the new
/// identifier as positionals, then `[INPUT]` through the shared workflow
/// options. `wright rename` covers raw Workshop input only; source languages
/// are rename surfaces of their providers.
#[derive(Debug, Args)]
pub(crate) struct RenameArgs {
/// The declared name of the variable or subroutine to rename.
#[arg(value_name = "NAME")]
pub(crate) name: String,
/// The new identifier.
#[arg(value_name = "NEW_NAME")]
pub(crate) to: String,
/// Apply the validated rename to the input file atomically instead of
/// previewing the diff.
#[arg(long)]
pub(crate) write: bool,
#[command(flatten)]
pub(crate) common: CommonArgs,
}

/// Arguments of `inspect`: an optional query subcommand naming one detail
/// area, plus the shared workflow options used by the bare summary.
#[derive(Debug, Args)]
Expand Down
6 changes: 6 additions & 0 deletions crates/wright-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,12 @@ fn run_workflow(command: Command) -> ExitCode {
wright_driver::CompilerSession::analyze,
)
}
Command::Rename(args) => run_configured(
config_from_common(&args.common, false),
present::Presentation::from_common(&args.common),
None,
move |session| session.rename(&args.name, &args.to, args.write),
),
Command::Lint(args) => {
let mut config = config_from_common(&args.common, true);
config.selection = selection_from_args(&args.select);
Expand Down
53 changes: 53 additions & 0 deletions crates/wright-cli/src/present.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ use std::time::Duration;

use wright_driver::Severity;
use wright_driver::config::OutputFormat;
use wright_driver::edit::RenameResult;
use wright_driver::progress::{ProgressEvent, ProgressObserver, ProgressPhase, ProgressUnit};
use wright_driver::result::{
AnalyzeResult, CallGraphResult, CfgResult, CheckResult, CompileResult, ConvertResult,
Expand Down Expand Up @@ -595,6 +596,22 @@ impl ResultPresentation for CheckResult {
fn render_body(&self, _ctx: &RenderContext<'_>) {}
}

impl ResultPresentation for RenameResult {
fn metadata(&self) -> Option<String> {
let transaction = self.transaction.as_ref()?;
let edits = transaction.edits.len();
let sources = self.preview.as_ref().map_or(0, Vec::len);
Some(if self.written.is_empty() {
format!("{edits} edit(s) across {sources} source(s); preview — pass --write to apply")
} else {
format!("{edits} edit(s) applied to {}", self.written.join(", "))
})
}
fn render_body(&self, _ctx: &RenderContext<'_>) {
render_rename(self);
}
}

impl ResultPresentation for AnalyzeResult {
fn metadata(&self) -> Option<String> {
let rules = count(&self.program, "rules");
Expand Down Expand Up @@ -1175,6 +1192,42 @@ fn render_convert(result: &ConvertResult) {
print!("{}", result.text);
}

/// The `rename` human report (#434): per-source, the lines the validated
/// transaction changes, shown as `-`/`+` pairs with their line numbers —
/// rename edits only ever rewrite identifier occurrences in place. Written
/// files are listed after the diff when `--write` applied them.
fn render_rename(result: &RenameResult) {
let Some(previews) = &result.preview else {
return;
};
for preview in previews {
println!("\n{}", preview.source);
let original = result
.originals
.get(&preview.source)
.map_or("", String::as_str);
let original_lines: Vec<&str> = original.split('\n').collect();
let edited_lines: Vec<&str> = preview.new_text.split('\n').collect();
let rows = original_lines.len().max(edited_lines.len());
for index in 0..rows {
match (original_lines.get(index), edited_lines.get(index)) {
(Some(old), Some(new)) if old == new => {}
(old, new) => {
if let Some(old) = old {
println!(" {:>4} - {}", index + 1, old);
}
if let Some(new) = new {
println!(" {:>4} + {}", index + 1, new);
}
}
}
}
}
for written in &result.written {
println!("wrote {written}");
}
}

fn array_len(value: &serde_json::Value) -> usize {
value.as_array().map_or(0, Vec::len)
}
Expand Down
24 changes: 24 additions & 0 deletions crates/wright-cli/tests/agent_contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,30 @@ fn agent_v1_schema_covers_every_advertised_request_and_response() {
);
}

// #434: `semanticRename` targets address a symbol by id or name, or by a
// position inside one identifier occurrence; both forms validate and
// deserialize.
for request in [
json!({"op":"semanticRename","sources":{},"target":{"symbol":0,"to":"renamed"}}),
json!({"op":"semanticRename","sources":{},"target":{"symbol":"score","to":"renamed"}}),
json!({"op":"semanticRename","sources":{},"target":{"source":"a.ws","line":1,"col":3,"to":"renamed"}}),
] {
assert!(
request_schema.is_valid(&request),
"invalid request: {request}"
);
serde_json::from_value::<ToolRequest>(request).expect("request deserializes");
}
for request in [
json!({"op":"semanticRename","sources":{},"target":{"symbol":true,"to":"x"}}),
json!({"op":"semanticRename","sources":{},"target":{"to":"x","extra":1}}),
] {
assert!(
!request_schema.is_valid(&request),
"schema accepted an invalid rename target: {request}"
);
}

let capabilities_response = json!({"result":current});
assert!(response_schema.is_valid(&capabilities_response));
assert!(response_schema.is_valid(&json!({
Expand Down
161 changes: 161 additions & 0 deletions crates/wright-cli/tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2168,6 +2168,42 @@ fn inspect_names_unnamed_rules_by_index() {
let _ = std::fs::remove_dir_all(path.parent().unwrap());
}

#[test]
fn rename_previews_a_validated_diff_without_writing() {
// #434: `wright rename` defaults to a source diff; the file stays
// untouched and no temporary file leaks next to it.
let original = corpus_workshop("synthetic/declarations-numbers");
let path = temp_file("rename.ws", &original);
let output = run(&[
"rename",
"score",
"total",
path.to_str().unwrap(),
"--kind",
"workshop",
]);
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("PASS rename"), "{stdout}");
assert!(stdout.contains("- 0: score"), "{stdout}");
assert!(stdout.contains("+ 0: total"), "{stdout}");
assert!(
stdout.contains("--write"),
"the preview names the apply flag"
);
assert_eq!(std::fs::read_to_string(&path).unwrap(), original);
assert_eq!(
path.parent().unwrap().read_dir().unwrap().count(),
1,
"no temporary sibling remains"
);
let _ = std::fs::remove_dir_all(path.parent().unwrap());
}

#[test]
fn inspect_overview_and_cfg_bound_large_detail() {
// A program larger than one screen: 13 rules, one of them a wide graph.
Expand Down Expand Up @@ -2206,3 +2242,128 @@ fn inspect_overview_and_cfg_bound_large_detail() {
);
let _ = std::fs::remove_dir_all(Path::new(path).parent().unwrap());
}

#[test]
fn rename_write_applies_the_validated_change_atomically() {
// #434: --write replaces the input; the result reparses cleanly.
let path = temp_file(
"rename.ws",
&corpus_workshop("synthetic/declarations-numbers"),
);
let path_str = path.to_str().unwrap();
let output = run(&[
"rename", "score", "total", path_str, "--kind", "workshop", "--write",
]);
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains(&format!("wrote {path_str}")), "{stdout}");
let text = std::fs::read_to_string(&path).unwrap();
assert!(text.contains("0: total"), "{text}");
assert!(text.contains("Set Global Variable(total, 5)"), "{text}");
assert!(!text.contains("score"), "{text}");
let check = run(&["check", path_str, "--kind", "workshop"]);
assert!(check.status.success(), "{}", command_result(&check));
let _ = std::fs::remove_dir_all(path.parent().unwrap());
}

#[test]
fn rename_reports_the_envelope_in_json_mode() {
let path = temp_file(
"rename.ws",
&corpus_workshop("synthetic/declarations-numbers"),
);
let output = run(&[
"rename",
"score",
"total",
path.to_str().unwrap(),
"--kind",
"workshop",
"-f",
"json",
]);
assert!(output.status.success(), "{}", command_result(&output));
let envelope = parse_json(&output.stdout);
assert_eq!(envelope["command"], "rename");
assert_eq!(envelope["ok"], true);
assert_eq!(envelope["wright"]["contract"], "wright-result/v1");
assert!(
envelope["result"]["transaction"]["edits"]
.as_array()
.unwrap()
.len()
>= 2,
"{envelope}"
);
assert!(
envelope["result"]["preview"][0]["new_text"]
.as_str()
.unwrap()
.contains("0: total"),
"{envelope}"
);
let _ = std::fs::remove_dir_all(path.parent().unwrap());
}

#[test]
fn rename_refusals_carry_structured_diagnostics_and_write_nothing() {
// #434: unknown names, collisions, and non-Workshop input refuse with the
// structured codes; --write still writes nothing.
let original = corpus_workshop("synthetic/declarations-numbers");
let path = temp_file("rename.ws", &original);
for (name, code) in [
("missing", "unknown-symbol"),
("numbers", "rename-unsupported-kind"),
] {
let output = run(&[
"rename",
name,
"renamed",
path.to_str().unwrap(),
"--kind",
"workshop",
"-f",
"json",
"--write",
]);
assert_eq!(output.status.code(), Some(1), "{name}");
let envelope = parse_json(&output.stdout);
assert_eq!(envelope["diagnostics"][0]["code"], code, "{envelope}");
assert_eq!(std::fs::read_to_string(&path).unwrap(), original);
}

// OPY input routes to the provider operation rather than renaming
// through the Workshop path.
let opy = temp_file("program.opy", "rule \"r\":\n pass\n");
let output = run(&[
"rename",
"r",
"renamed",
opy.to_str().unwrap(),
"--kind",
"opy",
"-f",
"json",
]);
assert_eq!(output.status.code(), Some(1));
let envelope = parse_json(&output.stdout);
assert_eq!(envelope["diagnostics"][0]["code"], "edit-requires-provider");
assert!(
envelope["diagnostics"][0]["message"]
.as_str()
.unwrap()
.contains("providerSemanticRename"),
"{}",
envelope["diagnostics"][0]["message"]
);
assert_eq!(
std::fs::read_to_string(&opy).unwrap(),
"rule \"r\":\n pass\n"
);
let _ = std::fs::remove_dir_all(path.parent().unwrap());
let _ = std::fs::remove_dir_all(opy.parent().unwrap());
}
Loading
Loading