use std::collections::HashMap;
use tower_lsp::lsp_types::{
CodeAction, CodeActionKind, CodeActionOrCommand, Position, Range, TextEdit, Url, WorkspaceEdit,
};
use crate::text::selected_text_range;
pub fn extract_constant_actions(source: &str, range: Range, uri: &Url) -> Vec<CodeActionOrCommand> {
if range.start == range.end {
return vec![];
}
let selected = selected_text_range(source, range);
let trimmed = selected.trim();
if trimmed.is_empty() || !is_literal(trimmed) {
return vec![];
}
let const_name = derive_const_name(trimmed);
let lines: Vec<&str> = source.lines().collect();
let sel_line = range.start.line as usize;
match find_class_scope(&lines, sel_line) {
Some((insert_line, kind)) => {
let insert_pos = Position {
line: insert_line as u32 + 1,
character: 0,
};
let decl = match kind {
ContainerKind::Interface => format!(" const {const_name} = {trimmed};\n"),
ContainerKind::ClassOrTrait => {
format!(" private const {const_name} = {trimmed};\n")
}
};
let reference = format!("self::{const_name}");
build_action("Extract constant", decl, insert_pos, reference, range, uri)
}
None => {
let insert_line = file_scope_insert_line(&lines);
let insert_pos = Position {
line: insert_line as u32,
character: 0,
};
let decl = format!("const {const_name} = {trimmed};\n");
build_action("Extract constant", decl, insert_pos, const_name, range, uri)
}
}
}
fn is_literal(s: &str) -> bool {
is_string_literal(s) || is_int_literal(s) || is_float_literal(s)
}
fn is_string_literal(s: &str) -> bool {
(s.starts_with('"') && s.ends_with('"') && s.len() >= 2)
|| (s.starts_with('\'') && s.ends_with('\'') && s.len() >= 2)
}
fn is_int_literal(s: &str) -> bool {
!s.is_empty() && s.chars().all(|c| c.is_ascii_digit())
}
fn is_float_literal(s: &str) -> bool {
let mut dots = 0u32;
!s.is_empty()
&& s.chars().all(|c| {
if c == '.' {
dots += 1;
dots == 1
} else {
c.is_ascii_digit()
}
})
&& dots == 1
}
fn derive_const_name(literal: &str) -> String {
if is_string_literal(literal) {
let inner = &literal[1..literal.len() - 1];
derive_name_from_string(inner)
} else {
let sanitised = literal.replace('.', "_");
format!("CONSTANT_{sanitised}")
}
}
fn derive_name_from_string(s: &str) -> String {
let raw: String = s
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '_' })
.collect::<String>()
.to_uppercase();
let mut name = String::new();
let mut prev_under = true;
for c in raw.chars() {
if c == '_' {
if !prev_under {
name.push('_');
}
prev_under = true;
} else {
name.push(c);
prev_under = false;
}
}
let name = name.trim_end_matches('_').to_string();
let name = if name.starts_with(|c: char| c.is_ascii_digit()) {
format!("CONSTANT_{name}")
} else {
name
};
if name.is_empty() {
"EXTRACTED_CONSTANT".to_string()
} else {
name
}
}
#[derive(Debug, PartialEq)]
enum ContainerKind {
ClassOrTrait,
Interface,
}
fn find_class_scope(lines: &[&str], sel_line: usize) -> Option<(usize, ContainerKind)> {
for i in (0..=sel_line).rev() {
let line = lines[i].trim();
if let Some(kind) = container_kind(line) {
for (j, brace_line) in lines.iter().enumerate().skip(i) {
if brace_line.contains('{') {
if find_matching_close(lines, j)
.is_some_and(|close| sel_line > j && sel_line < close)
{
return Some((j, kind));
}
break;
}
}
}
}
None
}
fn find_matching_close(lines: &[&str], open_line: usize) -> Option<usize> {
let mut depth = 0i32;
let mut in_block_comment = false;
for (i, line) in lines.iter().enumerate().skip(open_line) {
let bytes = line.as_bytes();
let mut j = 0;
while j < bytes.len() {
if in_block_comment {
if j + 1 < bytes.len() && bytes[j] == b'*' && bytes[j + 1] == b'/' {
in_block_comment = false;
j += 2;
} else {
j += 1;
}
continue;
}
match bytes[j] {
b'"' | b'\'' => {
let quote = bytes[j];
j += 1;
while j < bytes.len() {
if bytes[j] == b'\\' {
j += 2; } else if bytes[j] == quote {
j += 1;
break;
} else {
j += 1;
}
}
}
b'/' if j + 1 < bytes.len() && bytes[j + 1] == b'/' => break, b'#' => break, b'/' if j + 1 < bytes.len() && bytes[j + 1] == b'*' => {
in_block_comment = true;
j += 2;
}
b'{' => {
depth += 1;
j += 1;
}
b'}' => {
depth -= 1;
if depth == 0 {
return Some(i);
}
j += 1;
}
_ => j += 1,
}
}
}
None
}
fn container_kind(line: &str) -> Option<ContainerKind> {
let stripped = line
.trim_start_matches("abstract ")
.trim_start_matches("final ")
.trim_start_matches("readonly ");
if stripped.starts_with("class ")
|| stripped.starts_with("class{")
|| stripped.starts_with("trait ")
|| stripped.starts_with("trait{")
{
Some(ContainerKind::ClassOrTrait)
} else if stripped.starts_with("interface ") || stripped.starts_with("interface{") {
Some(ContainerKind::Interface)
} else {
None
}
}
fn file_scope_insert_line(lines: &[&str]) -> usize {
let mut last_preamble = 0usize;
for (i, line) in lines.iter().enumerate() {
let t = line.trim();
if t.starts_with("<?php")
|| t.is_empty()
|| t.starts_with("namespace ")
|| t.starts_with("use ")
{
last_preamble = i + 1;
} else {
break;
}
}
last_preamble
}
fn build_action(
title: &str,
decl: String,
insert_pos: Position,
reference: String,
replace_range: Range,
uri: &Url,
) -> Vec<CodeActionOrCommand> {
let mut changes = HashMap::new();
changes.insert(
uri.clone(),
vec![
TextEdit {
range: Range {
start: insert_pos,
end: insert_pos,
},
new_text: decl,
},
TextEdit {
range: replace_range,
new_text: reference,
},
],
);
vec![CodeActionOrCommand::CodeAction(CodeAction {
title: title.to_string(),
kind: Some(CodeActionKind::REFACTOR_EXTRACT),
edit: Some(WorkspaceEdit {
changes: Some(changes),
..Default::default()
}),
..Default::default()
})]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn derive_name_from_url_string() {
assert_eq!(
derive_name_from_string("https://api.example.com"),
"HTTPS_API_EXAMPLE_COM"
);
}
#[test]
fn derive_name_empty_string_fallback() {
assert_eq!(derive_name_from_string("!!!"), "EXTRACTED_CONSTANT");
}
#[test]
fn derive_name_leading_digit_prefixed() {
assert_eq!(derive_name_from_string("42abc"), "CONSTANT_42ABC");
}
}