Skip to content

Commit 7e80b7e

Browse files
committed
Add define completion
1 parent 791d476 commit 7e80b7e

6 files changed

Lines changed: 602 additions & 34 deletions

File tree

src/completion/builder.rs

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -612,4 +612,75 @@ impl Backend {
612612

613613
(items, is_incomplete)
614614
}
615+
616+
// ─── Constant name completion ───────────────────────────────────
617+
618+
/// Build completion items for standalone constants (`define()` constants)
619+
/// from all known sources.
620+
///
621+
/// Sources (in priority order):
622+
/// 1. Constants discovered from parsed files (`global_defines`)
623+
/// 2. Built-in PHP constants from embedded stubs (`stub_constant_index`)
624+
///
625+
/// Each item uses the constant name as `label` and the source as `detail`.
626+
/// Items are deduplicated by name.
627+
///
628+
/// Returns `(items, is_incomplete)`. When the total number of
629+
/// matching constants exceeds [`MAX_CONSTANT_COMPLETIONS`], the result
630+
/// is truncated and `is_incomplete` is `true`.
631+
const MAX_CONSTANT_COMPLETIONS: usize = 100;
632+
633+
pub(crate) fn build_constant_completions(&self, prefix: &str) -> (Vec<CompletionItem>, bool) {
634+
let prefix_lower = prefix.to_lowercase();
635+
let mut seen: HashSet<String> = HashSet::new();
636+
let mut items: Vec<CompletionItem> = Vec::new();
637+
638+
// ── 1. User-defined constants (from parsed files) ───────────
639+
if let Ok(dmap) = self.global_defines.lock() {
640+
for (name, _uri) in dmap.iter() {
641+
if !name.to_lowercase().contains(&prefix_lower) {
642+
continue;
643+
}
644+
if !seen.insert(name.clone()) {
645+
continue;
646+
}
647+
items.push(CompletionItem {
648+
label: name.clone(),
649+
kind: Some(CompletionItemKind::CONSTANT),
650+
detail: Some("define constant".to_string()),
651+
insert_text: Some(name.clone()),
652+
filter_text: Some(name.clone()),
653+
sort_text: Some(format!("5_{}", name.to_lowercase())),
654+
..CompletionItem::default()
655+
});
656+
}
657+
}
658+
659+
// ── 2. Built-in PHP constants from stubs ────────────────────
660+
for &name in self.stub_constant_index.keys() {
661+
if !name.to_lowercase().contains(&prefix_lower) {
662+
continue;
663+
}
664+
if !seen.insert(name.to_string()) {
665+
continue;
666+
}
667+
items.push(CompletionItem {
668+
label: name.to_string(),
669+
kind: Some(CompletionItemKind::CONSTANT),
670+
detail: Some("PHP constant".to_string()),
671+
insert_text: Some(name.to_string()),
672+
filter_text: Some(name.to_string()),
673+
sort_text: Some(format!("6_{}", name.to_lowercase())),
674+
..CompletionItem::default()
675+
});
676+
}
677+
678+
let is_incomplete = items.len() > Self::MAX_CONSTANT_COMPLETIONS;
679+
if is_incomplete {
680+
items.sort_by(|a, b| a.sort_text.cmp(&b.sort_text));
681+
items.truncate(Self::MAX_CONSTANT_COMPLETIONS);
682+
}
683+
684+
(items, is_incomplete)
685+
}
615686
}

src/lib.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,13 @@ pub struct Backend {
7474
/// time, and also from any opened/changed files that contain standalone
7575
/// function declarations.
7676
pub global_functions: Arc<Mutex<HashMap<String, (String, FunctionInfo)>>>,
77+
/// Global constants defined via `define('NAME', value)` calls.
78+
///
79+
/// Maps constant name → file URI where it was defined.
80+
/// Populated from files listed in Composer's `autoload_files.php` at init
81+
/// time, and also from any opened/changed files that contain `define()`
82+
/// calls. Used to offer constant name completions alongside class names.
83+
pub global_defines: Arc<Mutex<HashMap<String, String>>>,
7784
/// Index of fully-qualified class names to file URIs.
7885
///
7986
/// This allows reliable lookup of classes that don't follow PSR-4
@@ -133,6 +140,7 @@ impl Backend {
133140
use_map: Arc::new(Mutex::new(HashMap::new())),
134141
namespace_map: Arc::new(Mutex::new(HashMap::new())),
135142
global_functions: Arc::new(Mutex::new(HashMap::new())),
143+
global_defines: Arc::new(Mutex::new(HashMap::new())),
136144
class_index: Arc::new(Mutex::new(HashMap::new())),
137145
classmap: Arc::new(Mutex::new(HashMap::new())),
138146
stub_index: stubs::build_stub_class_index(),
@@ -154,6 +162,7 @@ impl Backend {
154162
use_map: Arc::new(Mutex::new(HashMap::new())),
155163
namespace_map: Arc::new(Mutex::new(HashMap::new())),
156164
global_functions: Arc::new(Mutex::new(HashMap::new())),
165+
global_defines: Arc::new(Mutex::new(HashMap::new())),
157166
class_index: Arc::new(Mutex::new(HashMap::new())),
158167
classmap: Arc::new(Mutex::new(HashMap::new())),
159168
stub_index: stubs::build_stub_class_index(),
@@ -178,6 +187,7 @@ impl Backend {
178187
use_map: Arc::new(Mutex::new(HashMap::new())),
179188
namespace_map: Arc::new(Mutex::new(HashMap::new())),
180189
global_functions: Arc::new(Mutex::new(HashMap::new())),
190+
global_defines: Arc::new(Mutex::new(HashMap::new())),
181191
class_index: Arc::new(Mutex::new(HashMap::new())),
182192
classmap: Arc::new(Mutex::new(HashMap::new())),
183193
stub_index,
@@ -207,6 +217,7 @@ impl Backend {
207217
use_map: Arc::new(Mutex::new(HashMap::new())),
208218
namespace_map: Arc::new(Mutex::new(HashMap::new())),
209219
global_functions: Arc::new(Mutex::new(HashMap::new())),
220+
global_defines: Arc::new(Mutex::new(HashMap::new())),
210221
class_index: Arc::new(Mutex::new(HashMap::new())),
211222
classmap: Arc::new(Mutex::new(HashMap::new())),
212223
stub_index,
@@ -232,6 +243,7 @@ impl Backend {
232243
use_map: Arc::new(Mutex::new(HashMap::new())),
233244
namespace_map: Arc::new(Mutex::new(HashMap::new())),
234245
global_functions: Arc::new(Mutex::new(HashMap::new())),
246+
global_defines: Arc::new(Mutex::new(HashMap::new())),
235247
class_index: Arc::new(Mutex::new(HashMap::new())),
236248
classmap: Arc::new(Mutex::new(HashMap::new())),
237249
stub_index: stubs::build_stub_class_index(),

src/parser.rs

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,21 @@ impl Backend {
188188
functions
189189
}
190190

191+
/// Parse PHP source text and extract constant names from `define()` calls.
192+
///
193+
/// Returns a list of constant name strings for every `define('NAME', …)`
194+
/// call found at the top level, inside namespace blocks, block
195+
/// statements, or `if` guards.
196+
pub fn parse_defines(&self, content: &str) -> Vec<String> {
197+
let arena = Bump::new();
198+
let file_id = mago_database::file::FileId::new("input.php");
199+
let program = parse_file_content(&arena, file_id, content);
200+
201+
let mut defines = Vec::new();
202+
Self::extract_defines_from_statements(program.statements.iter(), &mut defines);
203+
defines
204+
}
205+
191206
/// Parse PHP source text and extract `use` statement mappings.
192207
///
193208
/// Returns a `HashMap` mapping short (imported) names to their
@@ -510,6 +525,102 @@ impl Backend {
510525
}
511526
}
512527

528+
// ─── define() constant extraction ───────────────────────────────
529+
530+
/// Walk statements and extract constant names from `define()` calls.
531+
///
532+
/// Handles top-level `define('NAME', value)` calls, as well as those
533+
/// nested inside namespace blocks, block statements, and `if` guards
534+
/// (the common `if (!defined('X')) { define('X', …); }` pattern).
535+
///
536+
/// Uses the parsed AST rather than regex, so it piggybacks on the
537+
/// parse pass that `update_ast` already performs.
538+
pub(crate) fn extract_defines_from_statements<'a>(
539+
statements: impl Iterator<Item = &'a Statement<'a>>,
540+
defines: &mut Vec<String>,
541+
) {
542+
for statement in statements {
543+
match statement {
544+
Statement::Expression(expr_stmt) => {
545+
if let Some(name) = Self::try_extract_define_name(expr_stmt.expression) {
546+
defines.push(name);
547+
}
548+
}
549+
Statement::Namespace(namespace) => {
550+
Self::extract_defines_from_statements(namespace.statements().iter(), defines);
551+
}
552+
Statement::Block(block) => {
553+
Self::extract_defines_from_statements(block.statements.iter(), defines);
554+
}
555+
Statement::If(if_stmt) => {
556+
Self::extract_defines_from_if_body(&if_stmt.body, defines);
557+
}
558+
_ => {}
559+
}
560+
}
561+
}
562+
563+
/// Helper: recurse into an `if` statement body to extract `define()`
564+
/// calls. Mirrors `extract_functions_from_if_body`.
565+
fn extract_defines_from_if_body<'a>(body: &'a IfBody<'a>, defines: &mut Vec<String>) {
566+
match body {
567+
IfBody::Statement(body) => {
568+
Self::extract_defines_from_statements(std::iter::once(body.statement), defines);
569+
for else_if in body.else_if_clauses.iter() {
570+
Self::extract_defines_from_statements(
571+
std::iter::once(else_if.statement),
572+
defines,
573+
);
574+
}
575+
if let Some(else_clause) = &body.else_clause {
576+
Self::extract_defines_from_statements(
577+
std::iter::once(else_clause.statement),
578+
defines,
579+
);
580+
}
581+
}
582+
IfBody::ColonDelimited(body) => {
583+
Self::extract_defines_from_statements(body.statements.iter(), defines);
584+
for else_if in body.else_if_clauses.iter() {
585+
Self::extract_defines_from_statements(else_if.statements.iter(), defines);
586+
}
587+
if let Some(else_clause) = &body.else_clause {
588+
Self::extract_defines_from_statements(else_clause.statements.iter(), defines);
589+
}
590+
}
591+
}
592+
}
593+
594+
/// Try to extract the constant name from a `define('NAME', …)` call
595+
/// expression. Returns `Some(name)` if the expression is a function
596+
/// call to `define` whose first argument is a string literal.
597+
fn try_extract_define_name(expr: &Expression<'_>) -> Option<String> {
598+
if let Expression::Call(Call::Function(func_call)) = expr {
599+
let func_name = match func_call.function {
600+
Expression::Identifier(ident) => ident.value(),
601+
_ => return None,
602+
};
603+
if !func_name.eq_ignore_ascii_case("define") {
604+
return None;
605+
}
606+
let args: Vec<_> = func_call.argument_list.arguments.iter().collect();
607+
if args.is_empty() {
608+
return None;
609+
}
610+
let first_expr = match &args[0] {
611+
Argument::Positional(pos) => pos.value,
612+
Argument::Named(named) => named.value,
613+
};
614+
if let Expression::Literal(Literal::String(lit_str)) = first_expr
615+
&& let Some(value) = lit_str.value
616+
&& !value.is_empty()
617+
{
618+
return Some(value.to_string());
619+
}
620+
}
621+
None
622+
}
623+
513624
pub(crate) fn extract_classes_from_statements<'a>(
514625
statements: impl Iterator<Item = &'a Statement<'a>>,
515626
classes: &mut Vec<ClassInfo>,
@@ -1021,6 +1132,20 @@ impl Backend {
10211132
}
10221133
}
10231134

1135+
// Extract define() constants from the already-parsed AST and
1136+
// store them in the global_defines map so they appear in
1137+
// completions. This reuses the parse pass above rather than
1138+
// doing a separate regex scan over the raw content.
1139+
let mut define_names = Vec::new();
1140+
Self::extract_defines_from_statements(program.statements.iter(), &mut define_names);
1141+
if !define_names.is_empty()
1142+
&& let Ok(mut dmap) = self.global_defines.lock()
1143+
{
1144+
for name in define_names {
1145+
dmap.entry(name).or_insert_with(|| uri.to_string());
1146+
}
1147+
}
1148+
10241149
// Post-process: resolve parent_class short names to fully-qualified
10251150
// names using the file's use_map and namespace so that cross-file
10261151
// inheritance resolution can find parent classes via PSR-4.

src/server.rs

Lines changed: 26 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -97,10 +97,16 @@ impl LanguageServer for Backend {
9797
*cm = classmap;
9898
}
9999

100-
// Parse autoload_files.php to discover global function definitions.
101-
// Also follow `require_once` statements in those files to discover
102-
// additional classes and functions (used by packages like Trustly
103-
// that don't follow composer conventions).
100+
// Parse autoload_files.php to discover global symbols.
101+
// These files can contain any kind of PHP symbol (classes,
102+
// functions, define() constants, etc.). Classes, traits,
103+
// interfaces, and enums can also be loaded via PSR-4 / classmap,
104+
// but functions and define() constants can *only* be discovered
105+
// through these files.
106+
//
107+
// We also follow `require_once` statements in those files to
108+
// discover additional files (used by packages like Trustly
109+
// that don't follow Composer conventions).
104110
let autoload_files = composer::parse_autoload_files(&root, &vendor_dir);
105111
let autoload_count = autoload_files.len();
106112

@@ -117,30 +123,11 @@ impl LanguageServer for Backend {
117123
}
118124

119125
if let Ok(content) = std::fs::read_to_string(&canonical) {
120-
let functions = self.parse_functions(&content);
121126
let uri = format!("file://{}", canonical.display());
122127

123-
if let Ok(mut fmap) = self.global_functions.lock() {
124-
for func in functions {
125-
let fqn = if let Some(ref ns) = func.namespace {
126-
format!("{}\\{}", ns, &func.name)
127-
} else {
128-
func.name.clone()
129-
};
130-
131-
// Insert both the FQN and the short name so that
132-
// callers using `use function Ns\func;` or bare
133-
// `func()` can both resolve.
134-
fmap.insert(fqn.clone(), (uri.clone(), func.clone()));
135-
if func.namespace.is_some() {
136-
fmap.entry(func.name.clone())
137-
.or_insert_with(|| (uri.clone(), func.clone()));
138-
}
139-
}
140-
}
141-
142-
// Also cache classes from these files in the ast_map so
143-
// that class definitions in autoload files are available.
128+
// Full AST parse: extracts classes, use statements,
129+
// namespaces, standalone functions, and define()
130+
// constants — all in a single pass.
144131
self.update_ast(&uri, &content);
145132

146133
// Follow require_once statements to discover more files.
@@ -477,22 +464,27 @@ impl LanguageServer for Backend {
477464
}
478465
}
479466

480-
// ── Class name completion ───────────────────────────────
467+
// ── Class name + constant completion ────────────────────
481468
// When there is no `->` or `::` operator, check whether the
482-
// user is typing a class name and offer completions from all
483-
// known sources (use-imports, same namespace, stubs, classmap,
484-
// class_index).
469+
// user is typing a class name or constant and offer
470+
// completions from all known sources (use-imports, same
471+
// namespace, stubs, classmap, class_index, global_defines,
472+
// stub_constant_index).
485473
if let Some(partial) = Self::extract_partial_class_name(&content, position) {
486-
let (class_items, is_incomplete) = self.build_class_name_completions(
474+
let (class_items, class_incomplete) = self.build_class_name_completions(
487475
&file_use_map,
488476
&file_namespace,
489477
&partial,
490478
&content,
491479
);
492-
if !class_items.is_empty() {
480+
let (constant_items, const_incomplete) = self.build_constant_completions(&partial);
481+
482+
if !class_items.is_empty() || !constant_items.is_empty() {
483+
let mut items = class_items;
484+
items.extend(constant_items);
493485
return Ok(Some(CompletionResponse::List(CompletionList {
494-
is_incomplete,
495-
items: class_items,
486+
is_incomplete: class_incomplete || const_incomplete,
487+
items,
496488
})));
497489
}
498490
}

0 commit comments

Comments
 (0)