From 5e561e4d79b72bb5de44cbdd876e229bcd9365e0 Mon Sep 17 00:00:00 2001 From: hongjr03 Date: Mon, 18 May 2026 22:14:59 +0800 Subject: [PATCH 1/2] feat(code-action): convert literal bases --- crates/ide/src/code_action.rs | 82 +++++++++- .../handlers/convert_literal_base.rs | 145 ++++++++++++++++++ 2 files changed, 226 insertions(+), 1 deletion(-) create mode 100644 crates/ide/src/code_action/handlers/convert_literal_base.rs diff --git a/crates/ide/src/code_action.rs b/crates/ide/src/code_action.rs index d2690077..17e5171e 100644 --- a/crates/ide/src/code_action.rs +++ b/crates/ide/src/code_action.rs @@ -434,11 +434,13 @@ mod handlers { mod add_instance_parens; mod add_missing_connections; mod add_missing_parameters; + mod convert_literal_base; mod convert_ordered_connections; mod remove_empty_port_connections; pub(crate) fn all() -> &'static [Handler] { &[ + convert_literal_base::convert_literal_base, add_missing_connections::add_missing_connections, add_missing_parameters::add_missing_parameters, convert_ordered_connections::convert_ordered_ports, @@ -510,6 +512,23 @@ mod tests { } fn apply_action_without_diagnostics(text: &str, action_name: &str) -> Option { + apply_action_without_diagnostics_by(text, |action| action.id.name == action_name) + } + + fn apply_action_without_diagnostics_with_label( + text: &str, + action_name: &str, + label: &str, + ) -> Option { + apply_action_without_diagnostics_by(text, |action| { + action.id.name == action_name && action.label == label + }) + } + + fn apply_action_without_diagnostics_by( + text: &str, + pred: impl Fn(&CodeAction) -> bool, + ) -> Option { let (db, file_id, offset) = db_with_file(text); let actions = code_action( &db, @@ -518,7 +537,7 @@ mod tests { CodeActionDiagnostics::default(), CodeActionResolveStrategy::All, ); - let action = actions.into_iter().find(|action| action.id.name == action_name)?; + let action = actions.into_iter().find(pred)?; let mut text = text.replace("/*caret*/", ""); let edit = action.source_change?.text_edits.remove(&file_id)?; edit.apply(&mut text); @@ -649,6 +668,67 @@ mod tests { ); } + #[test] + fn literal_base_converts_unsized_decimal_to_hexadecimal() { + let text = "module top; localparam int value = /*caret*/42; endmodule\n"; + let fixed = apply_action_without_diagnostics_with_label( + text, + "convert_literal_base", + "Convert literal to hexadecimal", + ) + .unwrap(); + + assert_eq!(fixed, "module top; localparam int value = 'h2a; endmodule\n"); + } + + #[test] + fn literal_base_preserves_size_and_signed_base() { + let text = "module top; localparam logic [7:0] value = /*caret*/8'sh2A; endmodule\n"; + let fixed = apply_action_without_diagnostics_with_label( + text, + "convert_literal_base", + "Convert literal to binary", + ) + .unwrap(); + + assert_eq!(fixed, "module top; localparam logic [7:0] value = 8'sb101010; endmodule\n"); + } + + #[test] + fn literal_base_converts_unsized_based_literal_to_decimal() { + let text = "module top; localparam int value = /*caret*/'hff; endmodule\n"; + let fixed = apply_action_without_diagnostics_with_label( + text, + "convert_literal_base", + "Convert literal to decimal", + ) + .unwrap(); + + assert_eq!(fixed, "module top; localparam int value = 255; endmodule\n"); + } + + #[test] + fn literal_base_preserves_unsized_signed_base() { + let text = "module top; localparam int value = /*caret*/'shff; endmodule\n"; + let fixed = apply_action_without_diagnostics_with_label( + text, + "convert_literal_base", + "Convert literal to decimal", + ) + .unwrap(); + + assert_eq!(fixed, "module top; localparam int value = 'sd255; endmodule\n"); + } + + #[test] + fn literal_base_is_not_available_for_string_literals() { + let labels = action_labels_without_diagnostics( + "module top; string value = /*caret*/\"42\"; endmodule\n", + ); + + assert!(!labels.iter().any(|label| label.starts_with("Convert literal to "))); + } + #[test] fn missing_connection_repair_fills_named_connections() { let text = "module child(input a, input b); endmodule\nmodule top; child u(/*caret*/.a()); endmodule\n"; diff --git a/crates/ide/src/code_action/handlers/convert_literal_base.rs b/crates/ide/src/code_action/handlers/convert_literal_base.rs new file mode 100644 index 00000000..9d93658b --- /dev/null +++ b/crates/ide/src/code_action/handlers/convert_literal_base.rs @@ -0,0 +1,145 @@ +use syntax::{ + LiteralBase, SVInt, + ast::{self, AstNode}, + has_text_range::HasTextRange, +}; +use utils::text_edit::TextRange; + +use crate::code_action::{CodeActionCollector, CodeActionCtx, CodeActionId, CodeActionKind}; + +const ID: CodeActionId = CodeActionId { + name: "convert_literal_base", + kind: CodeActionKind::RefactorRewrite, + repair: None, +}; + +pub(super) fn convert_literal_base( + collector: &mut CodeActionCollector, + ctx: &CodeActionCtx, +) -> Option<()> { + let literal = literal_at(ctx)?; + + for target_base in IntegerBase::ALL { + if target_base == literal.base { + continue; + } + + let label = format!("Convert literal to {}", target_base.label()); + let replacement = literal.render(target_base); + collector.add(ID, label, literal.range, |builder| { + builder.replace(literal.range, replacement); + }); + } + + Some(()) +} + +#[derive(Debug)] +struct IntegerLiteral { + range: TextRange, + value: SVInt, + base: IntegerBase, + size: Option, + signed: bool, +} + +impl IntegerLiteral { + fn render(&self, base: IntegerBase) -> String { + let digits = self.value.serialize(base.radix()); + let Some(size) = &self.size else { + if base == IntegerBase::Dec && !self.signed { + return digits; + } + + return format!("'{}{}{}", self.signed_specifier(), base.specifier(), digits); + }; + + format!("{size}'{}{}{}", self.signed_specifier(), base.specifier(), digits) + } + + fn signed_specifier(&self) -> &'static str { + if self.signed { "s" } else { "" } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum IntegerBase { + Bin, + Oct, + Dec, + Hex, +} + +impl IntegerBase { + const ALL: [Self; 4] = [Self::Bin, Self::Oct, Self::Dec, Self::Hex]; + + fn from_literal_base(base: LiteralBase) -> Self { + match base { + LiteralBase::Bin => Self::Bin, + LiteralBase::Oct => Self::Oct, + LiteralBase::Dec => Self::Dec, + LiteralBase::Hex => Self::Hex, + } + } + + fn radix(self) -> usize { + match self { + Self::Bin => 2, + Self::Oct => 8, + Self::Dec => 10, + Self::Hex => 16, + } + } + + fn specifier(self) -> &'static str { + match self { + Self::Bin => "b", + Self::Oct => "o", + Self::Dec => "d", + Self::Hex => "h", + } + } + + fn label(self) -> &'static str { + match self { + Self::Bin => "binary", + Self::Oct => "octal", + Self::Dec => "decimal", + Self::Hex => "hexadecimal", + } + } +} + +fn literal_at(ctx: &CodeActionCtx) -> Option { + if let Some(literal) = + ctx.find_node_at_offset::().and_then(integer_vector_literal) + { + return Some(literal); + } + + let literal = ctx.find_node_at_offset::()?; + let ast::LiteralExpression::IntegerLiteralExpression(integer) = literal else { + return None; + }; + + let token = integer.child_token(0)?; + Some(IntegerLiteral { + range: integer.text_range()?, + value: token.int()?, + base: IntegerBase::Dec, + size: None, + signed: false, + }) +} + +fn integer_vector_literal(literal: ast::IntegerVectorExpression) -> Option { + let base = literal.base()?; + let value = literal.value()?; + Some(IntegerLiteral { + range: literal.syntax().text_range()?, + value: value.int()?, + base: IntegerBase::from_literal_base(base.base()?), + size: literal.size().map(|size| size.raw_text().to_string()), + signed: base.raw_text().as_bytes().iter().any(|byte| byte.eq_ignore_ascii_case(&b's')), + }) +} From 270b9b0ded397f5e14b43c71361caed380710875 Mon Sep 17 00:00:00 2001 From: hongjr03 Date: Mon, 18 May 2026 23:18:57 +0800 Subject: [PATCH 2/2] fix(code-action): preserve literal base semantics --- crates/ide/src/code_action.rs | 31 ++++++++-- .../handlers/convert_literal_base.rs | 61 +++++++++++++------ 2 files changed, 70 insertions(+), 22 deletions(-) diff --git a/crates/ide/src/code_action.rs b/crates/ide/src/code_action.rs index 17e5171e..e6ddcfec 100644 --- a/crates/ide/src/code_action.rs +++ b/crates/ide/src/code_action.rs @@ -669,7 +669,7 @@ mod tests { } #[test] - fn literal_base_converts_unsized_decimal_to_hexadecimal() { + fn literal_base_converts_plain_decimal_to_sized_signed_hexadecimal() { let text = "module top; localparam int value = /*caret*/42; endmodule\n"; let fixed = apply_action_without_diagnostics_with_label( text, @@ -678,7 +678,20 @@ mod tests { ) .unwrap(); - assert_eq!(fixed, "module top; localparam int value = 'h2a; endmodule\n"); + assert_eq!(fixed, "module top; localparam int value = 32'sh2a; endmodule\n"); + } + + #[test] + fn literal_base_preserves_plain_decimal_sign_bit() { + let text = "module top; localparam longint value = /*caret*/2147483648; endmodule\n"; + let fixed = apply_action_without_diagnostics_with_label( + text, + "convert_literal_base", + "Convert literal to hexadecimal", + ) + .unwrap(); + + assert_eq!(fixed, "module top; localparam longint value = 33'sh80000000; endmodule\n"); } #[test] @@ -695,7 +708,7 @@ mod tests { } #[test] - fn literal_base_converts_unsized_based_literal_to_decimal() { + fn literal_base_converts_unsized_based_literal_to_based_decimal() { let text = "module top; localparam int value = /*caret*/'hff; endmodule\n"; let fixed = apply_action_without_diagnostics_with_label( text, @@ -704,7 +717,7 @@ mod tests { ) .unwrap(); - assert_eq!(fixed, "module top; localparam int value = 255; endmodule\n"); + assert_eq!(fixed, "module top; localparam int value = 'd255; endmodule\n"); } #[test] @@ -720,6 +733,16 @@ mod tests { assert_eq!(fixed, "module top; localparam int value = 'sd255; endmodule\n"); } + #[test] + fn literal_base_does_not_offer_decimal_for_unknown_bits() { + let labels = action_labels_without_diagnostics( + "module top; logic [3:0] value = /*caret*/'hx; endmodule\n", + ); + + assert!(labels.iter().any(|label| label == "Convert literal to binary")); + assert!(!labels.iter().any(|label| label == "Convert literal to decimal")); + } + #[test] fn literal_base_is_not_available_for_string_literals() { let labels = action_labels_without_diagnostics( diff --git a/crates/ide/src/code_action/handlers/convert_literal_base.rs b/crates/ide/src/code_action/handlers/convert_literal_base.rs index 9d93658b..95d75e83 100644 --- a/crates/ide/src/code_action/handlers/convert_literal_base.rs +++ b/crates/ide/src/code_action/handlers/convert_literal_base.rs @@ -24,8 +24,10 @@ pub(super) fn convert_literal_base( continue; } + let Some(replacement) = literal.render(target_base) else { + continue; + }; let label = format!("Convert literal to {}", target_base.label()); - let replacement = literal.render(target_base); collector.add(ID, label, literal.range, |builder| { builder.replace(literal.range, replacement); }); @@ -39,29 +41,51 @@ struct IntegerLiteral { range: TextRange, value: SVInt, base: IntegerBase, - size: Option, - signed: bool, + notation: IntegerLiteralNotation, } impl IntegerLiteral { - fn render(&self, base: IntegerBase) -> String { + fn render(&self, base: IntegerBase) -> Option { + if base == IntegerBase::Dec && self.value.has_unknown() { + return None; + } + let digits = self.value.serialize(base.radix()); - let Some(size) = &self.size else { - if base == IntegerBase::Dec && !self.signed { - return digits; + Some(match &self.notation { + IntegerLiteralNotation::PlainDecimal => { + format!("{}'s{}{}", self.plain_decimal_width(), base.specifier(), digits) } - - return format!("'{}{}{}", self.signed_specifier(), base.specifier(), digits); - }; - - format!("{size}'{}{}{}", self.signed_specifier(), base.specifier(), digits) + IntegerLiteralNotation::Based { size: Some(size), signed } => { + format!("{size}'{}{}{}", signed_specifier(*signed), base.specifier(), digits) + } + IntegerLiteralNotation::Based { size: None, signed } => { + format!("'{}{}{}", signed_specifier(*signed), base.specifier(), digits) + } + }) } - fn signed_specifier(&self) -> &'static str { - if self.signed { "s" } else { "" } + fn plain_decimal_width(&self) -> usize { + let width = self.value.get_bit_width(); + if width < 32 { + 32 + } else if self.value.is_signed() { + width + } else { + width + 1 + } } } +#[derive(Debug)] +enum IntegerLiteralNotation { + PlainDecimal, + Based { size: Option, signed: bool }, +} + +fn signed_specifier(signed: bool) -> &'static str { + if signed { "s" } else { "" } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum IntegerBase { Bin, @@ -127,8 +151,7 @@ fn literal_at(ctx: &CodeActionCtx) -> Option { range: integer.text_range()?, value: token.int()?, base: IntegerBase::Dec, - size: None, - signed: false, + notation: IntegerLiteralNotation::PlainDecimal, }) } @@ -139,7 +162,9 @@ fn integer_vector_literal(literal: ast::IntegerVectorExpression) -> Option