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
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ This extension integrates PHP_CodeSniffer with Zed Editor to provide real-time c
## Features

- **Real-time diagnostics** - See code style violations as you type
- **Code formatting** - Format files with phpcbf via `Cmd+Shift+I` or format-on-save
- **Zero configuration** - Works out of the box using PHPCS native defaults
- **Live configuration** - Settings changes apply immediately without restart
- **Auto-recovery** - Automatically handles deleted or invalid config files
Expand Down Expand Up @@ -263,6 +264,40 @@ If a config file becomes corrupted or references missing standards:
- **Workspace changes** - Config re-discovered when switching projects
- **File system changes** - Config errors trigger automatic re-discovery

## Formatting

The extension supports code formatting via PHPCBF (PHP Code Beautifier and Fixer). When triggered, it runs `phpcbf` on the current file content and applies all fixable changes.

### Enabling Format-on-Save

Add the following to your Zed `settings.json` (`Cmd+,` or `Ctrl+,`):

```json
{
"languages": {
"PHP": {
"language_servers": ["intelephense", "phpcs", "!phpactor"],
"formatter": {
"language_server": {
"name": "phpcs"
}
},
"format_on_save": "on"
}
}
}
```

> **Note:** Using `"name": "phpcs"` ensures Zed routes formatting to this extension rather than another language server like intelephense.

You can also format manually with `Cmd+Shift+I` (macOS) or `Ctrl+Shift+I` (Linux/Windows).

### How It Works

- Formatting uses the same coding standard as linting (phpcs.xml discovery, Zed settings, etc.)
- PHPCBF is discovered automatically using the same priority as PHPCS: project `vendor/bin` → user-configured path → `PHPCBF_PATH` env var → system PATH → bundled PHAR
- Formatting and linting run from the same LSP process — no extra configuration needed

## Troubleshooting

<details>
Expand Down
1 change: 0 additions & 1 deletion extension.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,3 @@ version = "0.3.0"
name = "PHPCS"
languages = ["PHP"]
language_ids = { PHP = "php" }
settings = { standard = "PSR12" }
82 changes: 71 additions & 11 deletions lsp-server/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -227,15 +227,7 @@ impl PhpcsLanguageServer {
) -> String {
let display = tool.display_name();

// 1. Check user-configured path
if let Ok(user_guard) = user_path.read() {
if let Some(path) = &*user_guard {
eprintln!("🎯 PHPCS LSP: Using user-configured {} path: {}", display, path);
return path.clone();
}
}

// 2. Check cache
// Check cache first
if let Ok(guard) = cache.read() {
if let Some(cached_path) = &*guard {
eprintln!("📂 PHPCS LSP: Using cached {} path: {}", display, cached_path);
Expand All @@ -245,9 +237,17 @@ impl PhpcsLanguageServer {

eprintln!("🔍 PHPCS LSP: Detecting {} path...", display);

// 3. Auto-detect and cache
// Gather inputs for detection
let user_path_val = user_path.read().ok().and_then(|guard| guard.clone());
let workspace_root = self.workspace_root.read().ok().and_then(|guard| guard.clone());
let path = tools::detect_tool_path(tool, workspace_root.as_deref());

// Detect with full priority order:
// vendor/bin → user config → env var → system PATH → bundled PHAR
let path = tools::detect_tool_path(
tool,
workspace_root.as_deref(),
user_path_val.as_deref(),
);

eprintln!("🎯 PHPCS LSP: Final {} path: {}", display, path);

Expand Down Expand Up @@ -997,6 +997,7 @@ impl LanguageServer for PhpcsLanguageServer {
..Default::default()
},
)),
document_formatting_provider: Some(OneOf::Left(true)),
code_action_provider: Some(CodeActionProviderCapability::Options(
CodeActionOptions {
code_action_kinds: Some(vec![CodeActionKind::QUICKFIX]),
Expand Down Expand Up @@ -1617,6 +1618,65 @@ impl LanguageServer for PhpcsLanguageServer {
eprintln!("✅ PHPCS LSP: Returning {} code actions for {}", code_actions.len(), file_name);
Ok(Some(code_actions))
}

async fn formatting(
&self,
params: DocumentFormattingParams,
) -> LspResult<Option<Vec<TextEdit>>> {
let uri = params.text_document.uri;
let file_name = uri.path_segments()
.and_then(|mut segments| segments.next_back())
.unwrap_or("unknown");

eprintln!("📐 PHPCS LSP: Formatting requested for {}", file_name);

// Get document content from compressed store
let content = {
let docs = self.open_docs.read().map_err(|_| {
tower_lsp::jsonrpc::Error::internal_error()
})?;

if let Some(compressed_doc) = docs.get(&uri) {
self.decompress_document(compressed_doc).ok()
} else {
None
}
};

let Some(content) = content else {
eprintln!("❌ PHPCS LSP: No document content for formatting {}", file_name);
return Ok(None);
};

// Run phpcbf to fix all issues (no sniff filter)
match self.run_phpcbf(&uri, &content, None).await {
Ok(fixed_content) => {
if fixed_content == content {
eprintln!("✅ PHPCS LSP: No formatting changes needed for {}", file_name);
return Ok(Some(vec![]));
}

// Return a full-document replacement edit
let line_count = content.lines().count() as u32;
let last_line_len = content.lines().last().map(|l| l.len() as u32).unwrap_or(0);

let edit = TextEdit {
range: Range {
start: Position { line: 0, character: 0 },
end: Position { line: line_count, character: last_line_len },
},
new_text: fixed_content,
};

eprintln!("✅ PHPCS LSP: Formatting applied for {}", file_name);
Ok(Some(vec![edit]))
}
Err(e) => {
eprintln!("❌ PHPCS LSP: Formatting failed for {}: {}", file_name, e);
Ok(None)
}
}
}
}

#[tokio::main]
Expand Down
42 changes: 35 additions & 7 deletions lsp-server/src/tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ impl PhpTool {
PhpTool::Phpcbf => "phpcbf.phar",
}
}

pub fn env_var_name(&self) -> &'static str {
match self {
PhpTool::Phpcs => "PHPCS_PATH",
PhpTool::Phpcbf => "PHPCBF_PATH",
}
}
}

/// Check if a command exists in the system PATH
Expand All @@ -57,11 +64,13 @@ pub fn command_exists(cmd: &str) -> bool {
}

/// Detect the path to a PHP tool using the following priority:
/// 1. Project vendor/bin/{tool}
/// 2. System {tool} (in PATH)
/// 3. Bundled {tool}.phar
/// 4. Fallback to tool name (will fail at runtime if not found)
pub fn detect_tool_path(tool: PhpTool, workspace_root: Option<&Path>) -> String {
/// 1. Project vendor/bin/{tool} (project-local Composer install)
/// 2. User-configured path from LSP settings
/// 3. Environment variable (PHPCS_PATH / PHPCBF_PATH)
/// 4. System {tool} (in PATH)
/// 5. Bundled {tool}.phar
/// 6. Fallback to tool name (will fail at runtime if not found)
pub fn detect_tool_path(tool: PhpTool, workspace_root: Option<&Path>, user_path: Option<&str>) -> String {
let display = tool.display_name();
let name = tool.name();

Expand All @@ -81,15 +90,34 @@ pub fn detect_tool_path(tool: PhpTool, workspace_root: Option<&Path>) -> String
eprintln!("❌ PHPCS LSP: No project-local {} found", display);
}

// Priority 2: System command
// Priority 2: User-configured path
if let Some(path) = user_path {
if !path.trim().is_empty() {
eprintln!("🎯 PHPCS LSP: Using user-configured {} path: {}", display, path);
return path.to_string();
}
}

// Priority 3: Environment variable
let env_var = tool.env_var_name();
eprintln!("🔍 PHPCS LSP: Checking {} env var for {}...", env_var, display);
if let Ok(path) = std::env::var(env_var) {
if !path.trim().is_empty() {
eprintln!("✅ PHPCS LSP: Found {} via {} env var", display, env_var);
return path;
}
}
eprintln!("❌ PHPCS LSP: No {} env var set", env_var);

// Priority 4: System command
eprintln!("🔍 PHPCS LSP: Checking for system {}...", name);
if command_exists(name) {
eprintln!("✅ PHPCS LSP: Found system {}", name);
return name.to_string();
}
eprintln!("❌ PHPCS LSP: No system {} found", name);

// Priority 3: Bundled PHAR
// Priority 5: Bundled PHAR
if let Ok(current_exe) = std::env::current_exe() {
if let Some(exe_dir) = current_exe.parent() {
let bundled = exe_dir.join(tool.phar_name());
Expand Down
Loading