mod config_index;
mod detect;
mod eloquent_guard;
mod env_index;
pub(crate) mod facades;
mod location_lookup;
pub(crate) mod request_fields;
mod route_index;
pub(crate) mod route_scaffold;
mod string_call;
mod translation_index;
mod view_index;
pub use config_index::ConfigIndex;
pub use eloquent_guard::unguarded_model_diagnostics;
pub use env_index::EnvIndex;
pub use route_index::RouteIndex;
pub use translation_index::TranslationIndex;
pub use view_index::ViewIndex;
use config_index::config_completions;
use env_index::{env_completions, missing_env_key_action};
use route_index::route_completions;
use string_call::{call_string_arg, call_string_prefix};
use translation_index::{missing_translation_json_key_action, translation_completions};
use view_index::view_completions;
use std::path::Path;
use tower_lsp_server::ls_types::{
CodeActionOrCommand, CompletionItem, Location, Position, Range, Uri,
};
pub(crate) use string_call::find_call_sites;
const ENV_CALL_NAMES: &[&str] = &["env"];
const CONFIG_CALL_NAMES: &[&str] = &["config"];
const VIEW_CALL_NAMES: &[&str] = &["view"];
const TRANS_CALL_NAMES: &[&str] = &["__", "trans"];
const ROUTE_CALL_NAMES: &[&str] = &["route"];
#[derive(Debug, Default)]
pub struct LaravelIndex {
pub is_laravel: bool,
pub env: EnvIndex,
pub config: ConfigIndex,
pub views: ViewIndex,
pub translations: TranslationIndex,
pub routes: RouteIndex,
}
impl LaravelIndex {
pub fn load(root: &Path) -> Self {
if !detect::is_laravel_project(root) {
return Self::default();
}
Self {
is_laravel: true,
env: EnvIndex::load(root),
config: ConfigIndex::load(root),
views: ViewIndex::load(root),
translations: TranslationIndex::load(root),
routes: RouteIndex::load(root),
}
}
}
pub(crate) fn route_call_at(
doc: &crate::document::ast::ParsedDoc,
position: Position,
) -> Option<(String, Range)> {
call_string_arg(doc, position, ROUTE_CALL_NAMES)
}
pub(crate) fn resolve_string_key(
doc: &crate::document::ast::ParsedDoc,
position: Position,
laravel: &LaravelIndex,
) -> Option<Location> {
if !laravel.is_laravel {
return None;
}
if let Some((key, _)) = call_string_arg(doc, position, ENV_CALL_NAMES) {
return laravel.env.get(&key).cloned();
}
if let Some((key, _)) = call_string_arg(doc, position, CONFIG_CALL_NAMES) {
return laravel.config.get(&key).cloned();
}
if let Some((key, _)) = call_string_arg(doc, position, VIEW_CALL_NAMES) {
return laravel.views.get(&key).cloned();
}
if let Some((key, _)) = call_string_arg(doc, position, TRANS_CALL_NAMES) {
return laravel.translations.get(&key).cloned();
}
if let Some((key, _)) = call_string_arg(doc, position, ROUTE_CALL_NAMES) {
return laravel.routes.get(&key).cloned();
}
None
}
pub(crate) fn completions_for_string_key(
source: &str,
position: Position,
laravel: Option<&LaravelIndex>,
) -> Option<Vec<CompletionItem>> {
let laravel = laravel.filter(|l| l.is_laravel)?;
if let Some(prefix) = call_string_prefix(source, position, ENV_CALL_NAMES) {
return Some(env_completions(&laravel.env, &prefix));
}
if let Some(prefix) = call_string_prefix(source, position, CONFIG_CALL_NAMES) {
return Some(config_completions(&laravel.config, &prefix));
}
if let Some(prefix) = call_string_prefix(source, position, VIEW_CALL_NAMES) {
return Some(view_completions(&laravel.views, &prefix));
}
if let Some(prefix) = call_string_prefix(source, position, TRANS_CALL_NAMES) {
return Some(translation_completions(&laravel.translations, &prefix));
}
if let Some(prefix) = call_string_prefix(source, position, ROUTE_CALL_NAMES) {
return Some(route_completions(&laravel.routes, &prefix));
}
None
}
pub(crate) fn resolve_definition_key(
uri: &Uri,
position: Position,
laravel: &LaravelIndex,
) -> Option<(&'static [&'static str], String, Location)> {
if !laravel.is_laravel {
return None;
}
if let Some(key) = laravel.env.key_at(uri, position) {
let loc = laravel.env.get(key)?.clone();
return Some((ENV_CALL_NAMES, key.to_string(), loc));
}
if let Some(key) = laravel.config.key_at(uri, position) {
let loc = laravel.config.get(key)?.clone();
return Some((CONFIG_CALL_NAMES, key.to_string(), loc));
}
if let Some(key) = laravel.views.key_at(uri, position) {
let loc = laravel.views.get(key)?.clone();
return Some((VIEW_CALL_NAMES, key.to_string(), loc));
}
if let Some(key) = laravel.translations.key_at(uri, position) {
let loc = laravel.translations.get(key)?.clone();
return Some((TRANS_CALL_NAMES, key.to_string(), loc));
}
if let Some(key) = laravel.routes.key_at(uri, position) {
let loc = laravel.routes.get(key)?.clone();
return Some((ROUTE_CALL_NAMES, key.to_string(), loc));
}
None
}
pub(crate) fn missing_key_actions(
doc: &crate::document::ast::ParsedDoc,
position: Position,
laravel: &LaravelIndex,
root: Option<&Path>,
) -> Vec<CodeActionOrCommand> {
if !laravel.is_laravel {
return Vec::new();
}
let Some(root) = root else {
return Vec::new();
};
let mut actions = Vec::new();
if let Some((key, _)) = call_string_arg(doc, position, ENV_CALL_NAMES)
&& laravel.env.get(&key).is_none()
&& let Some(action) = missing_env_key_action(root, &key)
{
actions.push(action);
}
if let Some((key, _)) = call_string_arg(doc, position, TRANS_CALL_NAMES)
&& laravel.translations.get(&key).is_none()
&& let Some(action) = missing_translation_json_key_action(root, &key)
{
actions.push(action);
}
actions
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn load_non_laravel_root_is_empty_and_inert() {
let tmp = tempfile::tempdir().unwrap();
let idx = LaravelIndex::load(tmp.path());
assert!(!idx.is_laravel);
assert_eq!(idx.env.names().count(), 0);
assert_eq!(idx.config.keys().count(), 0);
assert_eq!(idx.views.names().count(), 0);
assert_eq!(idx.translations.keys().count(), 0);
assert_eq!(idx.routes.names().count(), 0);
}
#[test]
fn load_laravel_root_builds_every_domain_index() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("artisan"), "#!/usr/bin/env php").unwrap();
std::fs::write(tmp.path().join(".env"), "APP_NAME=Test\n").unwrap();
std::fs::create_dir_all(tmp.path().join("config")).unwrap();
std::fs::write(
tmp.path().join("config").join("app.php"),
"<?php\nreturn ['name' => 'Test'];\n",
)
.unwrap();
std::fs::create_dir_all(tmp.path().join("resources").join("views")).unwrap();
std::fs::write(
tmp.path()
.join("resources")
.join("views")
.join("welcome.blade.php"),
"<h1>Hi</h1>",
)
.unwrap();
std::fs::create_dir_all(tmp.path().join("lang").join("en")).unwrap();
std::fs::write(
tmp.path().join("lang").join("en").join("auth.php"),
"<?php\nreturn ['failed' => 'Nope'];\n",
)
.unwrap();
std::fs::create_dir_all(tmp.path().join("routes")).unwrap();
std::fs::write(
tmp.path().join("routes").join("web.php"),
"<?php\nRoute::get('/', Foo::class)->name('home');\n",
)
.unwrap();
let idx = LaravelIndex::load(tmp.path());
assert!(idx.is_laravel);
assert!(idx.env.get("APP_NAME").is_some());
assert!(idx.config.get("app.name").is_some());
assert!(idx.views.get("welcome").is_some());
assert!(idx.translations.get("auth.failed").is_some());
assert!(idx.routes.get("home").is_some());
}
#[test]
fn resolve_string_key_none_for_non_laravel() {
let laravel = LaravelIndex::default();
let pos = Position {
line: 0,
character: 12,
};
let doc = crate::document::ast::ParsedDoc::parse("<?php\nenv('APP_NAME');\n".to_string());
assert!(resolve_string_key(&doc, pos, &laravel).is_none());
}
#[test]
fn completions_for_string_key_none_when_laravel_is_none() {
let pos = Position {
line: 1,
character: 5,
};
assert!(completions_for_string_key("<?php\nenv('", pos, None).is_none());
}
#[test]
fn resolve_definition_key_finds_env_var_declaration() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("artisan"), "#!/usr/bin/env php").unwrap();
std::fs::write(tmp.path().join(".env"), "APP_NAME=Test\n").unwrap();
let laravel = LaravelIndex::load(tmp.path());
let uri = Uri::from_file_path(tmp.path().join(".env")).unwrap();
let (names, key, _loc) = resolve_definition_key(
&uri,
Position {
line: 0,
character: 2,
},
&laravel,
)
.unwrap();
assert_eq!(names, ENV_CALL_NAMES);
assert_eq!(key, "APP_NAME");
}
#[test]
fn resolve_definition_key_none_when_not_on_a_definition() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("artisan"), "#!/usr/bin/env php").unwrap();
std::fs::write(tmp.path().join(".env"), "APP_NAME=Test\n").unwrap();
let laravel = LaravelIndex::load(tmp.path());
let uri = Uri::from_file_path(tmp.path().join("app.php")).unwrap();
assert!(
resolve_definition_key(
&uri,
Position {
line: 0,
character: 0
},
&laravel
)
.is_none()
);
}
#[test]
fn missing_key_actions_offers_env_quickfix_for_unresolved_key() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("artisan"), "#!/usr/bin/env php").unwrap();
std::fs::write(tmp.path().join(".env"), "APP_NAME=Test\n").unwrap();
let laravel = LaravelIndex::load(tmp.path());
let doc = crate::document::ast::ParsedDoc::parse("<?php\nenv('DB_HOST');\n".to_string());
let pos = Position {
line: 1,
character: 6,
};
let actions = missing_key_actions(&doc, pos, &laravel, Some(tmp.path()));
assert_eq!(actions.len(), 1);
let CodeActionOrCommand::CodeAction(action) = &actions[0] else {
panic!("expected a CodeAction");
};
assert_eq!(action.title, "Add 'DB_HOST' to .env");
}
#[test]
fn missing_key_actions_empty_when_key_already_resolves() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("artisan"), "#!/usr/bin/env php").unwrap();
std::fs::write(tmp.path().join(".env"), "APP_NAME=Test\n").unwrap();
let laravel = LaravelIndex::load(tmp.path());
let doc = crate::document::ast::ParsedDoc::parse("<?php\nenv('APP_NAME');\n".to_string());
let pos = Position {
line: 1,
character: 6,
};
assert!(missing_key_actions(&doc, pos, &laravel, Some(tmp.path())).is_empty());
}
#[test]
fn missing_key_actions_empty_for_non_laravel_workspace() {
let tmp = tempfile::tempdir().unwrap();
let laravel = LaravelIndex::load(tmp.path());
let doc = crate::document::ast::ParsedDoc::parse("<?php\nenv('DB_HOST');\n".to_string());
let pos = Position {
line: 1,
character: 6,
};
assert!(missing_key_actions(&doc, pos, &laravel, Some(tmp.path())).is_empty());
}
#[test]
fn missing_key_actions_empty_without_root() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("artisan"), "#!/usr/bin/env php").unwrap();
std::fs::write(tmp.path().join(".env"), "APP_NAME=Test\n").unwrap();
let laravel = LaravelIndex::load(tmp.path());
let doc = crate::document::ast::ParsedDoc::parse("<?php\nenv('DB_HOST');\n".to_string());
let pos = Position {
line: 1,
character: 6,
};
assert!(missing_key_actions(&doc, pos, &laravel, None).is_empty());
}
}