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
1 change: 1 addition & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **Laravel config and env key navigation.** "Go to Definition" and "Find All References" now work for string-literal config keys and env variables. Ctrl-clicking `config('app.name')` or any `Config` facade method that takes a key (`Config::get(...)`, `Config::string(...)`, `Config::boolean(...)`, etc.) jumps to the matching key declaration in `config/app.php`. Ctrl-clicking `env('APP_KEY')` jumps to the corresponding line in `.env`. "Find All References" on a config key lists every usage site plus the declaration in `config/*.php`. Contributed by @MingJen in https://github.com/AJenbo/phpantom_lsp/pull/93.
- **PHPStan assertType test runner.** A new `datatest_stable` harness (`tests/assert_type_runner.rs`) imports PHPStan's `assertType()` test corpus to validate PHPantom's type inference against the gold standard. Twenty-six files imported covering standalone types, native types, deducted types, casts, binary operators, class constants, multi-assignment chains, instanceof narrowing, `is_*()` function narrowing, `===`/`!==` null narrowing, isset narrowing, static late binding, `@var` annotations, promoted property types, method PHPDoc resolution, function PHPDoc resolution, enum types, mixed typehint resolution, nullable return type conflicts, array shape resolution, object shape property access, and array destructuring patterns. Seventeen type inference bugs discovered and filed.
- **Generics.** `@mixin` tags that reference a template parameter (e.g. `@template T of Node` combined with `@mixin T`) now resolve through the template bound, enabling completion and diagnostics for mixin-forwarded methods on generic wrapper classes.
- **Generics.** `new $var()` where `$var` is typed as `class-string<T>` now resolves to `T`, enabling completion on dynamically instantiated objects.
Expand Down
65 changes: 65 additions & 0 deletions example.php
Original file line number Diff line number Diff line change
Expand Up @@ -2088,6 +2088,34 @@ public function demo(): void
}


// ── Laravel Config & Env Navigation ─────────────────────────────────────────

class LaravelConfigEnvDemo
{
/**
* "Go to Definition" and "Find All References" for config keys and env vars.
*
* Try:
* 1. Ctrl+Click "app.name" to jump to config/app.php (mocked in tests).
* 2. Ctrl+Click "APP_KEY" to jump to .env (mocked in tests).
* 3. "Find All References" on "app.name" to see all usage sites.
*/
public function demo(): void
{
// Global helper
config('app.name');

// Facade methods
\Config::get('app.name');
\Illuminate\Support\Facades\Config::set('app.env', 'production');

// Env helper
env('APP_KEY');
env('DB_PASSWORD', 'secret');
}
}


// ── Callable Snippet Insertion ──────────────────────────────────────────────

class SnippetInsertionDemo
Expand Down Expand Up @@ -6558,9 +6586,28 @@ function runDemoAssertions(): void
$pen = $demo->getPens()->current();
assert($pen instanceof Pen, 'ArrayIterator<int, Pen>::current() must return Pen');

// ── Laravel Config ────────────────────────────────────────────────
assert(config('app.name', 'Default') === 'Default', 'config() should return default');
assert(\Config::get('app.name', 'Default') === 'Default', 'Config::get() should return default');

echo "All assertions passed.\n";
}

// ── Laravel Config (definition & references) ────────────────────────────────
// Try: Ctrl+Click 'app.name' or 'database.default' to jump to the declaration
// in config/app.php or config/database.php.
// Try: "Find All References" on 'app.name' to find other usages.

class LaravelConfigDemo
{
public function demo(): void
{
config('app.name');
\Config::get('database.default');
\Config::set('app.timezone', 'UTC');
}
}

runDemoAssertions();

} // end namespace Demo
Expand All @@ -6570,6 +6617,24 @@ function runDemoAssertions(): void
// above resolve Builder methods, relationship properties, and scope forwarding
// without requiring a real Laravel installation.

namespace {
/**
* Get / set the specified configuration value.
*
* @param array|string|null $key
* @param mixed $default
* @return mixed
*/
function config($key = null, $default = null) { return $default; }

class Config {
/** @return mixed */
public static function get(string $key, $default = null) { return $default; }
/** @return void */
public static function set(string $key, $value = null) {}
}
}

namespace Illuminate\Database\Eloquent {

abstract class Model {
Expand Down
24 changes: 21 additions & 3 deletions src/definition/resolve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ use crate::composer;
use crate::symbol_map::{SelfStaticParentKind, SymbolKind};
use crate::types::{AccessKind, ClassInfo};
use crate::util::{find_class_at_offset, position_to_offset, short_name};
use crate::virtual_members::laravel;

impl Backend {
/// Handle a "go to definition" request.
Expand All @@ -40,9 +41,22 @@ impl Backend {
// Consult precomputed symbol map (retries one byte earlier for
// end-of-token edge cases).
let symbol = self.lookup_symbol_at_position(uri, content, position);
symbol
.as_ref()
.and_then(|s| self.resolve_from_symbol(&s.kind, uri, content, position, s.start))
if let Some(ref s) = symbol
&& let Some(resolved) =
self.resolve_from_symbol(&s.kind, uri, content, position, s.start)
{
return Some(resolved);
}

// Laravel config fallback: declaration sites in config/*.php
if let Some(loc) =
laravel::resolve_config_key_definition_fallback(self, uri, content, position)
{
return Some(loc);
}

// env() fallback: not yet indexed in the symbol map.
laravel::resolve_env_definition(self, content, position)
}

/// Look up the symbol at the given byte offset in the precomputed
Expand Down Expand Up @@ -292,6 +306,10 @@ impl Backend {
// references only.
self.resolve_constant_definition(&candidates)
}

SymbolKind::LaravelStringKey { kind, key } => {
laravel::resolve_laravel_string_key(self, kind, key)
}
}
}

Expand Down
3 changes: 2 additions & 1 deletion src/definition/type_definition.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,8 @@ impl Backend {
SymbolKind::ClassDeclaration { .. }
| SymbolKind::MemberDeclaration { .. }
| SymbolKind::ConstantReference { .. }
| SymbolKind::NamespaceDeclaration { .. } => {
| SymbolKind::NamespaceDeclaration { .. }
| SymbolKind::LaravelStringKey { .. } => {
// No meaningful type definition target for these.
Vec::new()
}
Expand Down
4 changes: 3 additions & 1 deletion src/highlight/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,9 @@ impl Backend {
self.highlight_keyword(symbol_map, content, *ssp_kind, span.start, uri)
}
}
SymbolKind::NamespaceDeclaration { .. } => Vec::new(),
SymbolKind::NamespaceDeclaration { .. } | SymbolKind::LaravelStringKey { .. } => {
Vec::new()
}
};

if highlights.is_empty() {
Expand Down
2 changes: 2 additions & 0 deletions src/hover/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -808,6 +808,8 @@ impl Backend {
None => None,
}
}

SymbolKind::LaravelStringKey { .. } => None,
}
}

Expand Down
53 changes: 30 additions & 23 deletions src/references/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,9 @@ use crate::symbol_map::{SelfStaticParentKind, SymbolKind, SymbolMap};
use crate::types::{ClassInfo, MAX_INHERITANCE_DEPTH, ResolvedType};
use crate::util::{
build_fqn, collect_php_files_gitignore, find_class_at_offset, offset_to_position,
position_to_offset, strip_fqn_prefix,
position_to_offset, push_unique_location, strip_fqn_prefix,
};
use crate::virtual_members::laravel;

impl Backend {
/// Entry point for `textDocument/references`.
Expand Down Expand Up @@ -64,11 +65,20 @@ impl Backend {
sym.start,
include_declaration,
);
return if locations.is_empty() {
None
} else {
Some(locations)
};
if !locations.is_empty() {
return Some(locations);
}
}

// Fallback for declaration sites in config/*.php, where array keys are
// not in the symbol map and lookup_symbol_at_position returns None.
// Also handles cases where the cursor is on a string literal that was
// indexed as a ClassReference (e.g. 'User' => ...) but the user
// actually wants config references.
if let Some(locations) =
laravel::find_config_references(self, uri, content, position, include_declaration)
{
return Some(locations);
}

None
Expand Down Expand Up @@ -193,7 +203,19 @@ impl Backend {
Vec::new()
}
}

SymbolKind::NamespaceDeclaration { .. } => Vec::new(),

SymbolKind::LaravelStringKey { kind, key } => {
let snapshot = self.user_file_symbol_maps();
laravel::find_laravel_string_key_references(
self,
kind,
key,
&snapshot,
include_declaration,
)
}
}
}

Expand Down Expand Up @@ -371,7 +393,7 @@ impl Backend {
/// snapshot of every symbol map whose URI does not fall under the
/// vendor directory or the internal stub scheme. All four cross-file
/// reference scanners use this to restrict results to user code.
fn user_file_symbol_maps(&self) -> Vec<(String, Arc<SymbolMap>)> {
pub(crate) fn user_file_symbol_maps(&self) -> Vec<(String, Arc<SymbolMap>)> {
self.ensure_workspace_indexed();

let vendor_prefixes = self.vendor_uri_prefixes.lock().clone();
Expand Down Expand Up @@ -860,7 +882,7 @@ impl Backend {
///
/// This is a lightweight resolution path used during reference scanning.
/// It handles the common cases (`self`, `static`, `$this`, `parent`,
/// bare class names for static access, and typed `$variable` parameters)
/// bare class name for static access, and typed `$variable` parameters)
/// without the full weight of the completion resolver.
fn resolve_subject_to_fqns(
&self,
Expand Down Expand Up @@ -1391,20 +1413,5 @@ fn class_names_match(resolved: &str, target: &str, target_short: &str) -> bool {
false
}

/// Push a location only if it is not already present (deduplication).
fn push_unique_location(locations: &mut Vec<Location>, uri: &Url, start: Position, end: Position) {
let already_present = locations.iter().any(|l| {
l.uri == *uri
&& l.range.start.line == start.line
&& l.range.start.character == start.character
});
if !already_present {
locations.push(Location {
uri: uri.clone(),
range: Range { start, end },
});
}
}

#[cfg(test)]
mod tests;
1 change: 1 addition & 0 deletions src/rename/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -539,6 +539,7 @@ impl Backend {
SymbolKind::ConstantReference { name } => Some((name.clone(), range)),
SymbolKind::NamespaceDeclaration { name } => Some((name.clone(), range)),
SymbolKind::SelfStaticParent { .. } => None,
SymbolKind::LaravelStringKey { .. } => None,
}
}

Expand Down
2 changes: 2 additions & 0 deletions src/semantic_tokens.rs
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,8 @@ impl Backend {
(TT_ENUM_MEMBER, TM_READONLY)
}
}

SymbolKind::LaravelStringKey { .. } => continue,
};

if let Some(abs) =
Expand Down
77 changes: 74 additions & 3 deletions src/symbol_map/extraction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1591,10 +1591,17 @@ fn extract_from_expression<'a>(
start: ident.span().start.offset,
end: ident.span().end.offset,
kind: SymbolKind::FunctionCall {
name: name_clean,
name: name_clean.clone(),
is_definition: false,
},
});
if name_clean.eq_ignore_ascii_case("config") {
try_emit_config_key_span(
&func_call.argument_list,
ctx.content,
&mut ctx.spans,
);
}
}
_ => {
extract_from_expression(func_call.function, ctx, scope_start);
Expand Down Expand Up @@ -1685,13 +1692,38 @@ fn extract_from_expression<'a>(
start: ident.span.start.offset,
end: ident.span.end.offset,
kind: SymbolKind::MemberAccess {
subject_text,
member_name,
subject_text: subject_text.clone(),
member_name: member_name.clone(),
is_static: true,
is_method_call: true,
is_docblock_reference: false,
},
});
let clean_subject = strip_fqn_prefix(&subject_text);
if (clean_subject.eq_ignore_ascii_case("Config")
|| clean_subject
.eq_ignore_ascii_case("Illuminate\\Support\\Facades\\Config"))
&& matches!(
member_name.to_ascii_lowercase().as_str(),
"has"
| "get"
| "string"
| "integer"
| "float"
| "boolean"
| "array"
| "collection"
| "set"
| "prepend"
| "push"
)
{
try_emit_config_key_span(
&static_call.argument_list,
ctx.content,
&mut ctx.spans,
);
}
}
extract_from_arguments(&static_call.argument_list.arguments, ctx, scope_start);
}
Expand Down Expand Up @@ -2867,6 +2899,45 @@ fn is_assert_instanceof(expr: &Expression<'_>) -> bool {
false
}

/// If the first argument of `argument_list` is a non-empty, non-interpolated
/// string literal, push a [`SymbolKind::LaravelStringKey`] span covering the
/// string content (inside the quotes) onto `spans`.
///
/// Called by the `config()` function-call extractor and the
/// `Config::get()` / `Config::set()` static-call extractor so that
/// find-references and go-to-definition for Laravel config keys can use
/// the pre-built symbol map instead of re-parsing every file on demand.
fn try_emit_config_key_span(
argument_list: &ArgumentList<'_>,
content: &str,
spans: &mut Vec<SymbolSpan>,
) {
let Some(first_arg) = argument_list.arguments.iter().next() else {
return;
};
let Expression::Literal(literal::Literal::String(s)) = first_arg.value() else {
return;
};
let inner_start = s.span.start.offset + 1;
let inner_end = s.span.end.offset - 1;
if inner_start >= inner_end || inner_end as usize > content.len() {
return;
}
let key = &content[inner_start as usize..inner_end as usize];
if key.is_empty() || !key.contains('.') {
// Require at least one dot: bare keys like 'app' are not valid config paths.
return;
}
spans.push(SymbolSpan {
start: inner_start,
end: inner_end,
kind: SymbolKind::LaravelStringKey {
kind: crate::symbol_map::LaravelStringKind::Config,
key: key.to_string(),
},
});
}

/// Recursively check whether an expression contains an `instanceof` operator.
fn arg_contains_instanceof(expr: &Expression<'_>) -> bool {
match expr {
Expand Down
Loading
Loading