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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ Check [Keep a Changelog](http://keepachangelog.com/) for recommendations on how

## [Unreleased]

### Added

- **DelphiLSP settings generation** (`ddk delphilsp-config`, MCP `delphi_generate_delphilsp_config`, LSP `delphilsp/generate`): write the `<project>.delphilsp.json` settings file Embarcadero's DelphiLSP VS Code extension needs for code insight, without ever opening RAD Studio. The file is reconstructed from the project's `.dproj` (evaluated for the effective configuration/platform), the compiler's `rsvars.bat`, and the IDE's global Library Path / Browsing Path / environment-variable overrides read from the registry. Works on managed projects (ID/name) and ad-hoc `.dproj` paths (`-c` picks the compiler, `-o` overrides the destination). Files DDK writes carry a `"generatedBy": "delphi-devkit"` marker; IDE-generated files are never touched.
- **DelphiLSP auto-sync (VS Code)**: when the DelphiLSP extension is installed, DDK keeps its `delphiLsp.settingsFile` pointed at the DDK active project — generating the settings file on the fly when missing (or stale and DDK-owned) — and re-validates every open Delphi file under the new project context after each switch (something DelphiLSP's own *Select project settings* command does not do). New **Generate DelphiLSP Config** project context-menu action, and `ddk.delphilsp.autoSync` / `ddk.delphilsp.revalidateOpenFilesOnSwitch` settings (both on by default). Nothing appears when DelphiLSP is not installed.
- **Merged diagnostics (VS Code)**: with DelphiLSP installed, the Problems view no longer shows the same error twice (once from DDK's last compile, once from DelphiLSP's live analysis). DDK compile diagnostics matching a live DelphiLSP entry (same line, same error code) are removed in favor of the live entry's precise token range — and once DelphiLSP reports the error as fixed in the editor, the compile copy stays gone even without recompiling. Compile diagnostics DelphiLSP has no opinion on (unvalidated files, link errors) keep their normal lifetime; without DelphiLSP nothing is ever removed. `ddk.delphilsp.mergeDiagnostics` setting (on by default).

## [2.5.1] - 2026-07-31

### Added
Expand Down
59 changes: 59 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ This extension is currently developed in my free time, and any feedback is welco
* **Smart Navigation**: .dfm -> .pas jumps with Ctrl+click
* **Compiler Output Enhancements**: Timestamps, clickable file links, and diagnostics published to the Problems panel
* **Formatter Support**: Configurable Delphi code formatter.
* **DelphiLSP Settings Generation**: Generate the `.delphilsp.json` file Embarcadero's DelphiLSP extension needs for code insight, without ever opening RAD Studio
* **LSP Server**: Bundled `ddk-server` (Rust) handles all project state, compilation, and formatting
* **MCP Server**: Bundled `ddk-mcp-server` exposes project and compiler management as MCP tools for AI assistants (VS Code Copilot, Claude Desktop, etc.)
* **CLI** (`ddk`): Standalone command-line interface for managing projects and compilers outside of VS Code. Install via WinGet or use the bundled binary.
Expand Down Expand Up @@ -68,6 +69,10 @@ ddk run # Run the active project's executable
ddk run <ID|NAME> # Run a project by ID or name (= -p; lists candidates if ambiguous)
ddk run <PATH> # Run a .exe directly, or a .dproj/.dpr/.dpk's owning project
ddk run -a "-flag \"value with spaces\"" # Override the project's saved Start Parameters for this run
ddk delphilsp-config # Write the active project's .delphilsp.json (DelphiLSP code insight)
ddk delphilsp-config <ID|NAME|PATH> # ...for a specific project (lists candidates if ambiguous)
ddk delphilsp-config <PATH> -c "Delphi 12" # ...choosing the compiler for an unmanaged path
ddk delphilsp-config <PATH> -o <FILE> # ...writing somewhere else instead of next to the project
ddk env # Show active project & compiler info
ddk info # Print the DDK README
ddk --json <command> # Output as JSON
Expand Down Expand Up @@ -121,6 +126,60 @@ one run; the process is launched detached, so the CLI/MCP call returns
immediately without waiting for it to exit. The same is exposed to AI
tooling via the MCP `delphi_run_project` and `delphi_run_file` tools.

`ddk delphilsp-config` writes the `<project>.delphilsp.json` settings file that
Embarcadero's **DelphiLSP** VS Code extension reads for code insight
(completion, go-to-definition). That file is normally produced only when the
project is opened in the RAD Studio IDE, so code insight is dead for anyone
working purely in VS Code. DDK reconstructs it from the project's `.dproj`
(evaluated for the effective configuration/platform), the compiler
installation's `bin\rsvars.bat`, and the IDE's global **Library Path**,
**Browsing Path** and user-defined environment variables read from
`HKCU\SOFTWARE\Embarcadero\BDS\<version>`. Search-path entries whose
`$(MACRO)` cannot be resolved are dropped and reported as warnings. The target
resolves like `ddk compile`: an ID, a name, or a path (ad-hoc when the path
belongs to no workspace). Re-run it after changing the project's search paths,
defines, configuration or platform. The same is exposed to AI tooling via the
MCP `delphi_generate_delphilsp_config` tool, and to the VS Code extension via
the `delphilsp/generate` LSP request.

Every file DDK writes carries a top-level `"generatedBy": "delphi-devkit"` key
next to `"settings"`. RAD Studio never writes that key, so it marks the file as
DDK's to maintain: the VS Code extension refreshes a stale configuration only
when the marker is present, and leaves an IDE-generated or hand-written
`.delphilsp.json` alone. Running `ddk delphilsp-config` explicitly always
rewrites the file regardless of the marker.

In the VS Code extension the integration activates only when the DelphiLSP
extension (`EmbarcaderoTechnologies.delphilsp`) is installed — no commands or
menu items appear otherwise, and installing it later enables them on the fly.
A **Generate DelphiLSP Config** context-menu action is added to projects in
the DDK tree, and whenever the active project changes DDK keeps DelphiLSP in
sync automatically (`ddk.delphilsp.autoSync`, on by default): it points
DelphiLSP's `delphiLsp.settingsFile` at the active project's
`.delphilsp.json`, generating the file on the fly when it is missing — or
regenerating it when it is DDK-owned and older than its `.dproj` — so code
insight always resolves units, defines and search paths in the context of the
project you are actually working on. Because DelphiLSP's server applies a
settings change only to files opened afterwards (its own *Select project
settings* command leaves already-open files with stale diagnostics until they
are edited), DDK also re-validates every open Delphi file after the switch via
an invisible language-mode round-trip that re-emits `didOpen` — tabs, focus,
cursor, dirty state and undo history are untouched
(`ddk.delphilsp.revalidateOpenFilesOnSwitch`, on by default).

With both extensions active the **Problems** view would otherwise show the same
error twice — once from DDK's last compile and once from DelphiLSP's live
analysis — and DDK's copy would go stale the moment the code is fixed, since it
only refreshes on the next compile. DDK therefore merges the two: whenever
DelphiLSP reports live diagnostics for a file, every DDK compile diagnostic
matching one of them (same line, same `Exxxx` code) is removed, leaving only
the live entry with its precise token range — and when DelphiLSP later clears
that entry because the error was fixed in the editor (even without
recompiling), no stale compile copy resurfaces. Compile diagnostics DelphiLSP
knows nothing about (files it has not validated, link errors) keep their normal
lifetime, and without DelphiLSP installed nothing is ever removed
(`ddk.delphilsp.mergeDiagnostics`, on by default).

Without `--args`, a project runs with the `.dproj`'s own `Debugger_RunParams`
— the Run Parameters set via Project > Options > Run in the Delphi IDE,
resolved against the project's active configuration/platform — fused with
Expand Down
46 changes: 46 additions & 0 deletions cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,32 @@ enum Commands {
args: Option<String>,
},

/// Generate the `.delphilsp.json` settings file used by Embarcadero's
/// DelphiLSP VS Code extension (code insight), without needing RAD Studio.
///
/// TARGET may be a project ID, a project name, or a path to a
/// .dproj/.dpr/.dpk. A path owned by no workspace is handled ad-hoc; pick
/// its compiler with --compiler.
#[command(name = "delphilsp-config", visible_alias = "delphilsp_config")]
DelphiLspConfig {
/// What to describe: a project ID, a project name, or a path to a
/// .dproj/.dpr/.dpk. Omit to use the active project.
target: Option<String>,

/// Compiler configuration for an ad-hoc file TARGET: an exact key
/// (e.g. "12.0") or product name (e.g. "Delphi 12"). Defaults to the
/// newest installed compiler. Only meaningful when TARGET is a file
/// that belongs to no workspace.
#[arg(long, short = 'c')]
compiler: Option<String>,

/// Write the settings file here instead of next to the project's main
/// source (useful for inspecting the output without overwriting an
/// IDE-generated file).
#[arg(long, short = 'o')]
out: Option<String>,
},

/// Show environment info for the active project.
Env,

Expand Down Expand Up @@ -422,6 +448,26 @@ async fn main() -> Result<()> {
}
}

Commands::DelphiLspConfig { target, compiler, out } => {
use commands::DelphiLspOrAmbiguity;
match commands::cmd_delphilsp_config(target, compiler, out).await? {
DelphiLspOrAmbiguity::Output(result) => {
if cli.json {
println!("{}", serde_json::to_string_pretty(&result)?);
} else {
println!("{result}");
}
}
DelphiLspOrAmbiguity::Ambiguity(a) => {
if cli.json {
println!("{}", serde_json::to_string_pretty(&a)?);
} else {
print!("{a}");
}
}
}
}

Commands::Format { file, encoding } => {
let result = commands::cmd_format_file(file, encoding).await?;
if cli.json {
Expand Down
188 changes: 185 additions & 3 deletions core/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1612,16 +1612,198 @@ pub async fn cmd_run_exe(exe_path: String, args: Option<String>) -> Result<RunOu
/// [`cmd_run_file`]); a `.exe` runs directly (see [`cmd_run_exe`]). Used by
/// the CLI/MCP so both share one extension-dispatch rule.
pub async fn cmd_run_path(path: String, args: Option<String>) -> Result<RunOrAmbiguity> {
let lower = path.to_lowercase();
if lower.ends_with(".exe") {
if has_extension(&path, &["exe"]) {
return Ok(RunOrAmbiguity::Output(cmd_run_exe(path, args).await?));
}
if lower.ends_with(".dproj") || lower.ends_with(".dpr") || lower.ends_with(".dpk") {
if is_delphi_project_path(&path) {
return cmd_run_file(path, args).await;
}
bail!("\"{path}\" is not a recognized project or executable file (expected .dproj/.dpr/.dpk/.exe).");
}

/// Whether `value` names a Delphi project source (`.dproj`/`.dpr`/`.dpk`),
/// case-insensitively and without allocating.
fn is_delphi_project_path(value: &str) -> bool {
has_extension(value, &["dproj", "dpr", "dpk"])
}

fn has_extension(value: &str, extensions: &[&str]) -> bool {
std::path::Path::new(value)
.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| extensions.iter().any(|known| ext.eq_ignore_ascii_case(known)))
}

// ---------------------------------------------------------------------------
// DelphiLSP settings file
// ---------------------------------------------------------------------------

/// Result of generating a `.delphilsp.json`: either the written file's summary,
/// or the candidate list when the project reference was ambiguous.
#[derive(Debug, Clone)]
pub enum DelphiLspOrAmbiguity {
Output(crate::delphilsp::DelphiLspConfigResult),
Ambiguity(AmbiguousProjects),
}

/// Build the generation request for a project already managed by DDK.
async fn delphilsp_request_for_project(
project_id: usize,
out: Option<String>,
) -> Result<crate::delphilsp::GenerationRequest> {
use std::path::PathBuf;

let data = PROJECTS_DATA.read().await;
let project = match data.get_project(project_id) {
Some(p) => p,
_ => bail!("Project with ID {project_id} not found."),
};
let compiler = match data.compiler_for_project(project_id).await {
Some(c) => c,
_ => bail!(
"Project \"{}\" is not linked to a workspace or the group project, so no compiler can be determined.",
project.name
),
};
let dproj_path = project.dproj.as_ref().map(PathBuf::from);
let main_source = project
.dpr
.as_ref()
.or(project.dpk.as_ref())
.map(PathBuf::from)
.or_else(|| dproj_path.as_ref().and_then(|p| crate::files::dproj::get_main_source(p).ok()));
let main_source = match main_source {
Some(path) => path,
_ => bail!(
"Project \"{}\" has no .dpr/.dpk main source to describe.",
project.name
),
};
Ok(crate::delphilsp::GenerationRequest {
dproj_path,
main_source,
configuration: project.active_configuration.clone(),
platform: project.active_platform.clone(),
installation_path: PathBuf::from(&compiler.installation_path),
compiler_name: compiler.product_name.clone(),
out_path: out.map(PathBuf::from),
})
}

/// Build the generation request for a project file that belongs to no
/// workspace — the ad-hoc counterpart of [`cmd_compile_file`]'s ad-hoc mode.
/// Nothing is added to (or read from) the persisted project state.
async fn delphilsp_request_for_path(
file_path: &str,
compiler: Option<String>,
out: Option<String>,
) -> Result<crate::delphilsp::GenerationRequest> {
use crate::files::dproj::{find_dproj_file, get_main_source};
use std::path::PathBuf;

let path = normalize_path(file_path);
if !path.exists() {
bail!("File not found: {file_path}");
}
let is_dproj = path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.eq_ignore_ascii_case("dproj"))
.unwrap_or(false);
let (dproj_path, main_source) = if is_dproj {
(Some(path.clone()), get_main_source(&path)?)
} else {
(find_dproj_file(&path).ok(), path.clone())
};

let compiler_key = resolve_compiler_key(compiler).await?;
let configs = COMPILER_CONFIGURATIONS.read().await;
let config = match configs.get(&compiler_key) {
Some(c) => c,
_ => bail!("Compiler configuration \"{compiler_key}\" disappeared."),
};
Ok(crate::delphilsp::GenerationRequest {
dproj_path,
main_source,
configuration: None,
platform: None,
installation_path: PathBuf::from(&config.installation_path),
compiler_name: config.product_name.clone(),
out_path: out.map(PathBuf::from),
})
}

/// Generates the `.delphilsp.json` settings file Embarcadero's DelphiLSP VS
/// Code extension needs for code insight, so search paths, defines and unit
/// scope names are correct without ever opening the RAD Studio IDE.
///
/// `target` resolves exactly like the compile commands: `None` uses the active
/// project; a project id or name resolves against the managed projects (an
/// ambiguous name returns the candidate list instead of writing anything); a
/// path to a `.dproj`/`.dpr`/`.dpk` that belongs to a managed project is
/// treated as that project, and one owned by no project is handled **ad-hoc**
/// against the compiler chosen by `compiler` (default: the newest installed).
///
/// The file is written next to the project's main source as
/// `<stem>.delphilsp.json` unless `out` overrides the destination.
pub async fn cmd_delphilsp_config(
target: Option<String>,
compiler: Option<String>,
out: Option<String>,
) -> Result<DelphiLspOrAmbiguity> {
let request = match target {
None => {
let active_id = {
let data = PROJECTS_DATA.read().await;
data.active_project_id
};
match active_id {
Some(id) => delphilsp_request_for_project(id, out).await?,
_ => bail!("No active project selected."),
}
}
Some(reference) if is_delphi_project_path(&reference) => {
let managed_id = {
let data = PROJECTS_DATA.read().await;
match resolve_project_by_path(&data, &reference) {
ProjectResolution::Single(id) => Some(id),
ProjectResolution::Ambiguous(matches) => {
return Ok(DelphiLspOrAmbiguity::Ambiguity(AmbiguousProjects {
reference,
matches,
}));
}
ProjectResolution::NotFound => None,
}
};
match managed_id {
Some(id) => delphilsp_request_for_project(id, out).await?,
_ => delphilsp_request_for_path(&reference, compiler, out).await?,
}
}
Some(reference) => {
let resolved = {
let data = PROJECTS_DATA.read().await;
resolve_project_reference(&data, &reference)
};
match resolved {
ProjectResolution::Single(id) => delphilsp_request_for_project(id, out).await?,
ProjectResolution::Ambiguous(matches) => {
return Ok(DelphiLspOrAmbiguity::Ambiguity(AmbiguousProjects {
reference,
matches,
}));
}
ProjectResolution::NotFound => bail!(
"No project matches \"{reference}\". Use `list` to see available projects."
),
}
}
};

Ok(DelphiLspOrAmbiguity::Output(crate::delphilsp::generate(&request)?))
}

/// Formats a Delphi source file in-place.
///
/// Reads the file at `file_path`, decodes it with `encoding` (e.g. `"utf-8"`,
Expand Down
Loading