Skip to content
Open
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
31 changes: 25 additions & 6 deletions src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2050,11 +2050,15 @@ impl Backend {
/// [`laravel_commands`](Backend::laravel_commands) index.
///
/// Candidate files are those declaring a class whose short name ends in
/// `Command` (the near-universal Laravel/Symfony convention) or which
/// live under a `Console/Commands/` directory (so project commands with
/// unconventional names are still found). Each candidate is read once,
/// gated by a cheap byte pre-filter for a `signature`/`AsCommand`/`$name`
/// declaration before parsing, then scanned by
/// `Command` (the near-universal Laravel/Symfony convention), which live
/// under a `Console/Commands/` directory (so project commands with
/// unconventional names are still found), or any other non-vendor
/// project class (so commands registered via `withCommands()` in
/// `bootstrap/app.php` from arbitrary directories — e.g.
/// `app/Actions/Sync` — are indexed too). Each candidate is read once,
/// gated by a cheap byte pre-filter for a
/// `signature`/`Signature`/`AsCommand`/`$name` declaration before
/// parsing, then scanned by
/// [`scan_command_file`](crate::virtual_members::laravel::scan_command_file).
pub(crate) fn build_laravel_command_index(&self) {
let mut candidate_uris: std::collections::HashSet<String> =
Expand All @@ -2065,6 +2069,14 @@ impl Backend {
let short = fqn.rsplit('\\').next().unwrap_or(fqn);
if short.ends_with("Command") || uri.contains("/Console/Commands/") {
candidate_uris.insert(uri.to_string());
} else if !uri.contains("/vendor/") {
// A project class registered as a command outside the
// conventional locations — e.g. `withCommands()` in
// bootstrap/app.php pointing at an arbitrary directory
// such as app/Actions/Sync. scan_command_file's
// extends-Command / attribute checks decide whether the
// file really declares a command.
candidate_uris.insert(uri.to_string());
}
}
}
Expand All @@ -2076,6 +2088,7 @@ impl Backend {
};
let bytes = content.as_bytes();
let looks_like_command = memchr::memmem::find(bytes, b"signature").is_some()
|| memchr::memmem::find(bytes, b"Signature").is_some()
|| memchr::memmem::find(bytes, b"AsCommand").is_some()
|| memchr::memmem::find(bytes, b"$name").is_some();
if !looks_like_command {
Expand Down Expand Up @@ -2200,7 +2213,12 @@ impl Backend {
return;
}
let was_contributor = self.laravel_commands.read().has_uri(uri);
let looks_like_command_file = uri.ends_with("Command.php") || uri.contains("/Console/");
// Same candidate rule as the full build: a contributor file, a
// conventionally-named command file, or any non-vendor project file
// (commands registered via `withCommands()` may live anywhere).
let looks_like_command_file = uri.ends_with("Command.php")
|| uri.contains("/Console/")
|| (!uri.contains("/vendor/") && uri.ends_with(".php"));
if !was_contributor && !looks_like_command_file {
return;
}
Expand All @@ -2210,6 +2228,7 @@ impl Backend {
.filter(|content| {
let bytes = content.as_bytes();
memchr::memmem::find(bytes, b"signature").is_some()
|| memchr::memmem::find(bytes, b"Signature").is_some()
|| memchr::memmem::find(bytes, b"AsCommand").is_some()
|| memchr::memmem::find(bytes, b"$name").is_some()
})
Expand Down
164 changes: 161 additions & 3 deletions src/virtual_members/laravel/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,17 @@
//!
//! Laravel encodes console commands as classes extending
//! `Illuminate\Console\Command`. Each command declares a name through one
//! of three surfaces, all statically recoverable from source:
//! of four surfaces, all statically recoverable from source:
//!
//! - `#[Signature('app:sync {user} {--queue}')]` (Laravel 11+ attribute form)
//! - `protected $signature = 'app:sync {user} {--queue}';`
//! - `protected $name = 'app:sync';`
//! - `#[AsCommand(name: 'app:sync')]`
//!
//! Command aliases (extra names a command answers to) are recovered from
//! `#[Aliases([...])]` and the `aliases:` argument of `#[Signature]` /
//! `#[AsCommand]`, and are indexed alongside the primary name.
//!
//! This module scans project and vendor command classes for those literals
//! (see [`scan_command_file`]), parses the `$signature` grammar into
//! arguments and options ([`parse_signature`]), and stores everything in a
Expand Down Expand Up @@ -79,6 +84,9 @@ impl CommandSignature {
pub(crate) struct CommandEntry {
/// The command name, e.g. `app:sync` or `migrate`.
pub name: String,
/// Alternative names the command answers to (`#[Aliases]`,
/// `#[Signature(aliases:)]`, `#[AsCommand(aliases:)]`).
pub aliases: Vec<String>,
/// Best-effort fully-qualified class name (`App\Console\Commands\Sync`).
pub fqn: Option<String>,
/// URI of the file declaring the command.
Expand Down Expand Up @@ -127,6 +135,11 @@ impl LaravelCommandIndex {
by_name
.entry(entry.name.clone())
.or_insert_with(|| entry.clone());
for alias in &entry.aliases {
by_name
.entry(alias.clone())
.or_insert_with(|| entry.clone());
}
}
}
self.by_name = by_name;
Expand Down Expand Up @@ -459,6 +472,11 @@ fn command_from_class(
None => Some(bytes_to_string(class.name.value)),
};

// Alias names from #[Aliases([...])] (which wins, mirroring
// Illuminate\Console\Command::configureFromAttributes) or the
// `aliases:` argument of #[Signature] / #[AsCommand].
let aliases = command_aliases(class);

// 1. #[AsCommand(name: '...')] / #[AsCommand('...')].
if let Some((name, offset)) = as_command_name(class, content) {
let signature = signature_property_value(class, content)
Expand All @@ -469,35 +487,55 @@ fn command_from_class(
});
return Some(CommandEntry {
name,
aliases: aliases.clone(),
fqn,
uri: uri.to_string(),
name_offset: offset,
signature,
});
}

// 2. #[Signature('name {--opt}')] — the Laravel 11+ attribute form of
// `$signature`.
if let Some((sig, offset)) = signature_attribute_value(class, content) {
let signature = parse_signature(sig);
if signature.name.is_empty() {
return None;
}
return Some(CommandEntry {
name: signature.name.clone(),
aliases: aliases.clone(),
fqn,
uri: uri.to_string(),
name_offset: offset,
signature,
});
}

// 2. $signature = '...'.
// 3. $signature = '...'.
if let Some((sig, offset)) = signature_property_value(class, content) {
let signature = parse_signature(sig);
if signature.name.is_empty() {
return None;
}
return Some(CommandEntry {
name: signature.name.clone(),
aliases: aliases.clone(),
fqn,
uri: uri.to_string(),
name_offset: offset,
signature,
});
}

// 3. $name = '...'.
// 4. $name = '...'.
if let Some((name, offset)) = string_property_value(class, "name", content) {
if name.is_empty() {
return None;
}
return Some(CommandEntry {
name: name.clone(),
aliases: aliases.clone(),
fqn,
uri: uri.to_string(),
name_offset: offset,
Expand Down Expand Up @@ -536,6 +574,125 @@ fn as_command_name(class: &Class<'_>, content: &str) -> Option<(String, u32)> {
None
}

/// The string value of the first `#[Signature('...')]` attribute — the
/// Laravel 11+ attribute form of `$signature` — plus the inner byte offset of
/// the literal.
fn signature_attribute_value<'c>(class: &Class<'_>, content: &'c str) -> Option<(&'c str, u32)> {
for list in class.attribute_lists.iter() {
for attr in list.attributes.iter() {
if last_segment(attr.name.value()) != b"Signature" {
continue;
}
let Some(arg_list) = attr.argument_list.as_ref() else {
continue;
};
let Some(first) = arg_list.arguments.first() else {
continue;
};
let Some(expr) = first.value() else {
continue;
};
if let Some((value, start, _)) = extract_string_literal(expr, content) {
return Some((value, start as u32));
}
}
}
None
}

/// Collect the command's alias names, mirroring
/// `Illuminate\Console\Command::configureFromAttributes`: a standalone
/// `#[Aliases([...])]` attribute wins; otherwise the `aliases:` argument of
/// `#[Signature]` / `#[AsCommand]` is used.
fn command_aliases(class: &Class<'_>) -> Vec<String> {
let mut aliases = Vec::new();
for list in class.attribute_lists.iter() {
for attr in list.attributes.iter() {
let segment = last_segment(attr.name.value());
match segment {
// #[Aliases(['a', 'b'])] — the single positional argument.
b"Aliases" => {
return attr
.argument_list
.as_ref()
.and_then(|args| args.arguments.first())
.and_then(|arg| arg.value())
.and_then(|expr| string_array_literal(expr))
.unwrap_or_default();
}
// `aliases:` named argument (positional index for
// Signature(sig, aliases) / AsCommand(name, desc, aliases)).
b"Signature" | b"AsCommand" => {
let Some(arg_list) = attr.argument_list.as_ref() else {
continue;
};
let positional_index = if segment == b"Signature" { 1 } else { 2 };
let mut positional = 0usize;
let mut found: Option<Vec<String>> = None;
for arg in arg_list.arguments.iter() {
match arg {
PartialArgument::Named(named) => {
if bytes_to_string(named.name.value) == "aliases" {
found = string_array_literal(named.value);
}
}
PartialArgument::Positional(_) => {
if positional == positional_index && found.is_none() {
found = arg.value().and_then(|expr| string_array_literal(expr));
}
positional += 1;
}
PartialArgument::NamedPlaceholder(_)
| PartialArgument::Placeholder(_)
| PartialArgument::VariadicPlaceholder(_) => {}
}
}
if let Some(list) = found
&& !list.is_empty()
{
aliases = list;
}
}
_ => {}
}
}
}
aliases
}

/// Collect the string literals of a `['a', 'b']` array expression (or a bare
/// string literal, which Laravel also accepts).
fn string_array_literal(expr: &Expression<'_>) -> Option<Vec<String>> {
match expr {
Expression::Literal(Literal::String(s)) => s.value.map(|v| vec![bytes_to_string(v)]),
Expression::Array(arr) => Some(collect_array_strings(arr.elements.iter())),
Expression::LegacyArray(arr) => Some(collect_array_strings(arr.elements.iter())),
_ => None,
}
}

fn collect_array_strings<'a, 'b>(
elements: impl IntoIterator<Item = &'a ArrayElement<'b>>,
) -> Vec<String>
where
'b: 'a,
{
elements
.into_iter()
.filter_map(|el| match el {
ArrayElement::Value(v) => Some(v.value),
_ => None,
})
.filter_map(|inner| {
if let Expression::Literal(Literal::String(s)) = inner {
s.value.map(bytes_to_string)
} else {
None
}
})
.collect()
}

/// The `$signature` property's string value and the inner byte offset of the
/// literal.
fn signature_property_value<'c>(class: &Class<'_>, content: &'c str) -> Option<(&'c str, u32)> {
Expand Down Expand Up @@ -619,6 +776,7 @@ fn find_signature_at_offset(
if offset >= start
&& offset <= end
&& let Some((sig, _)) = signature_property_value(class, content)
.or_else(|| signature_attribute_value(class, content))
{
*out = Some(parse_signature(sig));
}
Expand Down
Loading
Loading