From b0f22daf131ac110e4e38428b215ec480c8224e4 Mon Sep 17 00:00:00 2001 From: csm101 Date: Sat, 25 Jul 2026 14:33:51 +0200 Subject: [PATCH 1/6] feat(delphilsp): generate .delphilsp.json settings from the dproj and the IDE registry Embarcadero's DelphiLSP VS Code extension reads a .delphilsp.json settings file that is normally produced only by the RAD Studio IDE, leaving code insight dead for anyone working purely in VS Code. ddk delphilsp-config reconstructs that file from the same sources the IDE uses: the .dproj (evaluated for the effective configuration/platform), the installation's rsvars.bat, and the IDE's global Library Path, Browsing Path and user-defined environment variables read from HKCU\SOFTWARE\Embarcadero\BDS\. The emitted dccOptions mirrors the IDE's output (verified entry-for-entry against IDE-generated files: identical -I/-U/-O/-R search paths, -D defines, -NS namespaces, -A aliases, output dirs and dllname). Targets resolve like ddk compile: project ID, name, or a .dproj path handled ad-hoc with an optional -c compiler; -o overrides the destination. Generated files carry a top-level "generatedBy": "delphi-devkit" marker so automation can tell them apart from IDE-generated ones, which are never touched. Exposed as the delphilsp-config CLI subcommand, the delphi_generate_delphilsp_config MCP tool, and the delphilsp/generate custom LSP method. All logic lives in the isolated core/src/delphilsp module; 26 inline unit tests plus 13 end-to-end tests against synthetic projects. --- Cargo.lock | 77 +++ cli/src/main.rs | 46 ++ core/Cargo.toml | 1 + core/src/commands.rs | 195 ++++++ core/src/delphilsp/mod.rs | 1108 ++++++++++++++++++++++++++++++++ core/src/delphilsp/registry.rs | 88 +++ core/src/lib.rs | 1 + core/src/lsp_types.rs | 15 + core/tests/commands.rs | 49 ++ core/tests/delphilsp.rs | 325 ++++++++++ mcp/src/handler.rs | 45 ++ server/src/main.rs | 26 + 12 files changed, 1976 insertions(+) create mode 100644 core/src/delphilsp/mod.rs create mode 100644 core/src/delphilsp/registry.rs create mode 100644 core/tests/delphilsp.rs diff --git a/Cargo.lock b/Cargo.lock index 40660d8..581e2a2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -332,6 +332,7 @@ dependencies = [ "tokio", "tower-lsp", "windows-sys 0.59.0", + "winreg", ] [[package]] @@ -2096,6 +2097,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -2123,6 +2133,21 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -2156,6 +2181,12 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -2168,6 +2199,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -2180,6 +2217,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -2204,6 +2247,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -2216,6 +2265,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -2228,6 +2283,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -2240,6 +2301,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -2252,6 +2319,16 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winreg" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/cli/src/main.rs b/cli/src/main.rs index 90b15f0..db34be1 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -127,6 +127,32 @@ enum Commands { args: Option, }, + /// 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, + + /// 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, + + /// 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, + }, + /// Show environment info for the active project. Env, @@ -370,6 +396,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 { diff --git a/core/Cargo.toml b/core/Cargo.toml index ed1b4e3..a4f2886 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -37,3 +37,4 @@ dproj-rs = "0.3.0" [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.59", features = ["Win32_NetworkManagement_WNet"] } +winreg = "0.52" diff --git a/core/src/commands.rs b/core/src/commands.rs index 5699a03..3b4e923 100644 --- a/core/src/commands.rs +++ b/core/src/commands.rs @@ -1484,6 +1484,201 @@ pub async fn cmd_run_path(path: String, args: Option) -> Result Option { + for workspace in &data.workspaces { + if workspace.project_links.iter().any(|l| l.project_id == project_id) { + return Some(workspace.compiler().await); + } + } + let group_project = data.group_project.as_ref()?; + group_project + .project_links + .iter() + .any(|l| l.project_id == project_id) + .then_some(())?; + Some(data.group_projects_compiler().await) +} + +/// Build the generation request for a project already managed by DDK. +async fn delphilsp_request_for_project( + project_id: usize, + out: Option, +) -> Result { + 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 compiler_for_project(&data, 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, + out: Option, +) -> Result { + 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 +/// `.delphilsp.json` unless `out` overrides the destination. +pub async fn cmd_delphilsp_config( + target: Option, + compiler: Option, + out: Option, +) -> Result { + let is_project_file = |value: &str| { + let lower = value.to_lowercase(); + lower.ends_with(".dproj") || lower.ends_with(".dpr") || lower.ends_with(".dpk") + }; + + 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_project_file(&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"`, diff --git a/core/src/delphilsp/mod.rs b/core/src/delphilsp/mod.rs new file mode 100644 index 0000000..b224192 --- /dev/null +++ b/core/src/delphilsp/mod.rs @@ -0,0 +1,1108 @@ +//! Generation of `.delphilsp.json` settings files. +//! +//! Embarcadero's **DelphiLSP** VS Code extension (code insight / completion) +//! reads a `.delphilsp.json` file sitting next to the project's +//! `.dpr`/`.dpk`. That file is normally produced by the RAD Studio IDE when a +//! project is opened, which means code insight is dead for anyone who never +//! opens the IDE. This module reconstructs the file from the same sources the +//! IDE uses: +//! +//! * the project's `.dproj` (evaluated for the effective config/platform), +//! * the compiler installation's `bin\rsvars.bat` (`$(BDS)`, `$(BDSCOMMONDIR)`…), +//! * the IDE's **global Library Path** and user-defined environment variable +//! overrides, both read from `HKCU\SOFTWARE\Embarcadero\BDS\`. +//! +//! The emitted `dccOptions` string mirrors what the IDE writes. It only feeds +//! code insight — it never drives a real build — so switches that cannot be +//! derived faithfully fall back to sane defaults. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail}; +use serde::{Deserialize, Serialize}; +use std::fmt; + +mod registry; +pub use registry::IdeLibrarySettings; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/// Value of the top-level `generatedBy` key DDK stamps on every file it +/// writes, as a sibling of `settings`. +/// +/// The RAD Studio IDE never writes this key, which makes it a reliable +/// ownership marker: a `.delphilsp.json` carrying it is DDK's to refresh +/// whenever the project's search paths, defines, configuration or platform go +/// stale, while a file without it was hand-made or produced by the IDE and +/// must never be overwritten automatically. +pub const GENERATED_BY_MARKER: &str = "delphi-devkit"; + +/// Unit aliases the RAD Studio IDE emits when a project defines none of its +/// own. Observed verbatim in IDE-generated `.delphilsp.json` files. +pub const DEFAULT_UNIT_ALIASES: &str = "Generics.Collections=System.Generics.Collections;\ +Generics.Defaults=System.Generics.Defaults;\ +WinTypes=Winapi.Windows;\ +WinProcs=Winapi.Windows;\ +DbiTypes=BDE;\ +DbiProcs=BDE;\ +DbiErrs=BDE"; + +// --------------------------------------------------------------------------- +// Macro expansion +// --------------------------------------------------------------------------- + +/// A case-insensitive `$(NAME)` variable map (Windows environment semantics). +/// +/// Names are also kept in their original spelling because `dproj-rs` resolves +/// `$(NAME)` through a **case-sensitive** map: seeding it needs the exact +/// casing the `.dproj` files use (`DCC_UnitSearchPath`, not `DCC_UNITSEARCHPATH`). +#[derive(Debug, Clone, Default)] +pub struct MacroMap { + /// Upper-cased keys — the lookup used by [`MacroMap::expand`]. + vars: HashMap, + /// The same entries under their original spelling. + original_case: HashMap, +} + +impl MacroMap { + pub fn new() -> Self { + Self::default() + } + + /// Insert a variable, overwriting any previous value. + pub fn set(&mut self, key: impl AsRef, value: impl Into) { + let key = key.as_ref(); + let value = value.into(); + self.vars.insert(key.to_ascii_uppercase(), value.clone()); + self.original_case.insert(key.to_string(), value); + } + + /// Insert a variable only when that name is not already defined. + pub fn set_default(&mut self, key: impl AsRef, value: impl Into) { + if self.get(key.as_ref()).is_none() { + self.set(key, value); + } + } + + /// Forget a variable, whatever casing it was defined with. + pub fn remove(&mut self, key: &str) { + let upper = key.to_ascii_uppercase(); + self.vars.remove(&upper); + self.original_case.retain(|k, _| k.to_ascii_uppercase() != upper); + } + + pub fn get(&self, key: &str) -> Option<&String> { + self.vars.get(&key.to_ascii_uppercase()) + } + + pub fn extend(&mut self, entries: I) + where + I: IntoIterator, + K: AsRef, + V: Into, + { + for (k, v) in entries { + self.set(k, v); + } + } + + /// Expand every `$(NAME)` reference. Unknown names are left **verbatim** + /// so callers can detect (and report) unresolved macros — this is the one + /// behavioural difference from MSBuild, which expands them to nothing. + /// + /// Expansion is iterative (a resolved value may itself contain macros) and + /// bounded so a self-referential definition cannot loop forever. + pub fn expand(&self, value: &str) -> String { + const MAX_PASSES: usize = 8; + let mut current = value.to_string(); + for _ in 0..MAX_PASSES { + if !current.contains("$(") { + break; + } + let next = self.expand_once(¤t); + if next == current { + break; + } + current = next; + } + current + } + + fn expand_once(&self, value: &str) -> String { + let mut out = String::with_capacity(value.len()); + let bytes: Vec = value.chars().collect(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == '$' && i + 1 < bytes.len() && bytes[i + 1] == '(' { + if let Some(close) = (i + 2..bytes.len()).find(|&j| bytes[j] == ')') { + let name: String = bytes[i + 2..close].iter().collect(); + match self.get(&name) { + Some(resolved) => out.push_str(resolved), + // Unknown: keep the token so the caller can warn. + _ => out.push_str(&format!("$({name})")), + } + i = close + 1; + continue; + } + } + out.push(bytes[i]); + i += 1; + } + out + } + + /// Seed environment for `dproj-rs` property-group evaluation. Every entry + /// appears both upper-cased and in its original spelling, because that + /// lookup is case-sensitive. + pub fn as_env(&self) -> HashMap { + let mut env = self.vars.clone(); + env.extend(self.original_case.iter().map(|(k, v)| (k.clone(), v.clone()))); + env + } +} + +// --------------------------------------------------------------------------- +// Path list helpers +// --------------------------------------------------------------------------- + +/// Strip trailing path separators (`c:\foo\` → `c:\foo`) without eating a +/// drive root (`c:\` stays `c:\`). +fn trim_trailing_separator(path: &str) -> &str { + let trimmed = path.trim_end_matches(['\\', '/']); + if trimmed.is_empty() || trimmed.ends_with(':') { + path + } else { + trimmed + } +} + +/// Split a `;`-separated path list, expand its macros, and drop entries that +/// still contain an unresolved `$(NAME)` (collecting a warning for each). +pub fn expand_path_list(raw: &str, macros: &MacroMap, warnings: &mut Vec) -> Vec { + let mut out = Vec::new(); + for entry in raw.split(';') { + let entry = entry.trim(); + if entry.is_empty() { + continue; + } + let expanded = macros.expand(entry); + if expanded.contains("$(") { + warnings.push(format!( + "Dropped search-path entry with unresolved macro: {expanded}" + )); + continue; + } + let cleaned = trim_trailing_separator(expanded.trim()); + if !cleaned.is_empty() { + out.push(cleaned.to_string()); + } + } + out +} + +/// Quote a dcc option value when it contains a space, the way the IDE does. +fn quote_if_needed(value: &str) -> String { + if value.contains(' ') { + format!("\"{value}\"") + } else { + value.to_string() + } +} + +/// Join a path list into a dcc option payload: `;`-separated, entries with +/// spaces double-quoted individually. +fn join_paths(paths: &[String]) -> String { + paths.iter().map(|p| quote_if_needed(p)).collect::>().join(";") +} + +// --------------------------------------------------------------------------- +// File URI +// --------------------------------------------------------------------------- + +/// Percent-encode a Windows path into the `file:///C%3A/dir/file.dpk` form the +/// IDE writes: backslashes become forward slashes and everything outside the +/// unreserved URI set is percent-encoded — including `:`, spaces, `(`, `)` and +/// `+`, all observed encoded in IDE-generated files. +pub fn path_to_file_uri(path: &Path) -> String { + const SAFE: &str = "-._~/"; + let normalized = path.to_string_lossy().replace('\\', "/"); + let mut encoded = String::with_capacity(normalized.len() + 8); + for byte in normalized.as_bytes() { + let ch = *byte as char; + if ch.is_ascii_alphanumeric() || SAFE.contains(ch) { + encoded.push(ch); + } else { + encoded.push_str(&format!("%{byte:02X}")); + } + } + format!("file:///{}", encoded.trim_start_matches('/')) +} + +/// Like [`path_to_file_uri`] but for a directory: the IDE always terminates +/// those URIs with a slash. +pub fn dir_to_file_uri(path: &Path) -> String { + let uri = path_to_file_uri(path); + if uri.ends_with('/') { uri } else { format!("{uri}/") } +} + +// --------------------------------------------------------------------------- +// dccOptions assembly +// --------------------------------------------------------------------------- + +/// Fully resolved inputs for [`build_dcc_options`]. Everything here is already +/// expanded — the builder does no macro resolution of its own, which keeps it +/// trivially unit-testable. +#[derive(Debug, Clone, Default)] +pub struct DccOptionsInput { + /// `true` for a `.dpk` (emits `-TX.bpl`). + pub is_package: bool, + /// `true` for a `.dpr` that builds a DLL (emits `-TX.dll`). + pub is_library: bool, + pub optimize: bool, + pub stack_frames: bool, + pub inlining_off: bool, + pub range_checking: Option, + pub overflow_checking: Option, + /// `-A` payload (already `;`-joined). + pub unit_aliases: String, + /// `-D` payload (already `;`-joined, inheritance token removed). + pub defines: String, + /// `-NS` payload (already `;`-joined, inheritance token removed). + pub namespaces: String, + /// `-E` payload. + pub exe_output: String, + /// `-NU` payload. + pub dcu_output: String, + /// `-LE` payload. + pub bpl_output: String, + /// `-LN` payload. + pub dcp_output: String, + /// Prepended to `-I` and `-U` only (the IDE's "Debug DCU path"); `None` + /// for non-debug configurations. + pub debug_dcu_path: Option, + /// The full library/unit search path: the project's own entries followed + /// by the IDE's global Library Path. + pub search_paths: Vec, + /// `.dcp` names from `` (emitted as `-LU`). + pub required_packages: Vec, + /// `DCC_Description` (emitted as `--description:"…"`). + pub description: Option, +} + +impl DccOptionsInput { + /// Target extension for `-TX`. + fn target_extension(&self) -> &'static str { + if self.is_package { + ".bpl" + } else if self.is_library { + ".dll" + } else { + ".exe" + } + } +} + +/// Append `` (quoted when it contains a space), skipping empty +/// values so the IDE's "absent option" behaviour is preserved. +fn push_value(parts: &mut Vec, prefix: &str, value: &str) { + if !value.is_empty() { + parts.push(format!("{prefix}{}", quote_if_needed(value))); + } +} + +/// Assemble the single-line `dccOptions` string, in the order the RAD Studio +/// IDE emits it. +pub fn build_dcc_options(input: &DccOptionsInput) -> String { + let mut parts: Vec = Vec::new(); + + let flag = |on: bool| if on { '+' } else { '-' }; + parts.push(format!("-$O{}", flag(input.optimize))); + parts.push(format!("-$W{}", flag(input.stack_frames))); + if input.inlining_off { + parts.push("--inline:off".to_string()); + } + if let Some(on) = input.range_checking { + parts.push(format!("-$R{}", flag(on))); + } + if let Some(on) = input.overflow_checking { + parts.push(format!("-$Q{}", flag(on))); + } + // Always present in IDE output: no dcc32.cfg, quiet, emit "never build" dcps. + parts.push("--no-config".to_string()); + parts.push("-Q".to_string()); + parts.push("-Z".to_string()); + parts.push(format!("-TX{}", input.target_extension())); + + push_value(&mut parts, "-A", &input.unit_aliases); + push_value(&mut parts, "-D", &input.defines); + push_value(&mut parts, "-E", &input.exe_output); + + // -I / -U additionally see the IDE's debug DCU directory; -O / -R do not. + let with_debug_dcus: Vec = match &input.debug_dcu_path { + Some(debug) => std::iter::once(debug.clone()) + .chain(input.search_paths.iter().cloned()) + .collect(), + _ => input.search_paths.clone(), + }; + let include_and_unit = join_paths(&with_debug_dcus); + let object_and_resource = join_paths(&input.search_paths); + + if !include_and_unit.is_empty() { + parts.push(format!("-I{include_and_unit}")); + } + push_value(&mut parts, "-LE", &input.bpl_output); + push_value(&mut parts, "-LN", &input.dcp_output); + push_value(&mut parts, "-NU", &input.dcu_output); + push_value(&mut parts, "-NS", &input.namespaces); + if !object_and_resource.is_empty() { + parts.push(format!("-O{object_and_resource}")); + parts.push(format!("-R{object_and_resource}")); + } + if !include_and_unit.is_empty() { + parts.push(format!("-U{include_and_unit}")); + } + if let Some(description) = input.description.as_ref().filter(|d| !d.trim().is_empty()) { + parts.push(format!("--description:\"{description}\"")); + } + if !input.required_packages.is_empty() { + parts.push(format!("-LU{};", input.required_packages.join(";"))); + } + + parts.join(" ") +} + +// --------------------------------------------------------------------------- +// The generated file +// --------------------------------------------------------------------------- + +/// The `settings` object of a `.delphilsp.json`, field-for-field as the RAD +/// Studio IDE writes it. +#[derive(Debug, Clone, Serialize, Deserialize)] +struct DelphiLspSettings { + project: String, + dllname: String, + #[serde(rename = "dccOptions")] + dcc_options: String, + /// Extra files to treat as part of the project. The IDE leaves this empty; + /// the unit list is derived from the search paths instead. + #[serde(rename = "projectFiles")] + project_files: Vec, + #[serde(rename = "includeDCUsInUsesCompletion")] + include_dcus_in_uses_completion: bool, + #[serde(rename = "enableKeyWordCompletion")] + enable_keyword_completion: bool, + /// Directories DelphiLSP may navigate into (the IDE's Browsing Path). + #[serde(rename = "browsingPaths")] + browsing_paths: Vec, + /// `%APPDATA%\Embarcadero\BDS\\`. + #[serde(rename = "CommonAppData")] + common_app_data: String, + /// `\ObjRepos\`. + #[serde(rename = "Templates")] + templates: String, +} + +/// A whole `.delphilsp.json`. The RAD Studio IDE writes only `settings`; DDK +/// adds the [`GENERATED_BY_MARKER`] alongside it so a file it owns can be told +/// apart from one the IDE produced. +#[derive(Debug, Clone, Serialize, Deserialize)] +struct DelphiLspFile { + settings: DelphiLspSettings, + /// Absent on IDE-generated files — see [`GENERATED_BY_MARKER`]. + #[serde(rename = "generatedBy", skip_serializing_if = "Option::is_none")] + generated_by: Option, +} + +/// Outcome of generating a `.delphilsp.json` file. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DelphiLspConfigResult { + /// Absolute path of the file that was written. + pub file_path: String, + /// Project this configuration describes (`.dproj` when there is one). + pub project_file: String, + /// `file:///…` URI of the `.dpr`/`.dpk` main source. + pub project_uri: String, + /// Compiler DLL DelphiLSP should load, e.g. `dcc64290.dll`. + pub dllname: String, + pub configuration: String, + pub platform: String, + /// Compiler installation the settings were derived from. + pub compiler: String, + pub search_path_count: usize, + pub browsing_path_count: usize, + pub define_count: usize, + /// Non-fatal problems (unresolved macros, missing registry data, …). + pub warnings: Vec, +} + +impl fmt::Display for DelphiLspConfigResult { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + writeln!(f, "Wrote {}", self.file_path)?; + writeln!(f, " Project: {}", self.project_file)?; + writeln!(f, " Target: {} / {}", self.configuration, self.platform)?; + writeln!(f, " Compiler: {} ({})", self.compiler, self.dllname)?; + write!( + f, + " Search path: {} entries, {} browsing paths, {} defines", + self.search_path_count, self.browsing_path_count, self.define_count + )?; + for warning in &self.warnings { + write!(f, "\n ! {warning}")?; + } + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// Generation +// --------------------------------------------------------------------------- + +/// Everything the generator needs about the target, resolved by the caller. +#[derive(Debug, Clone)] +pub struct GenerationRequest { + /// The project's `.dproj`. `None` for a bare `.dpr`/`.dpk` without one. + pub dproj_path: Option, + /// The `.dpr`/`.dpk` main source (used for the `project` URI). + pub main_source: PathBuf, + /// Build configuration override; `None` uses the `.dproj` default. + pub configuration: Option, + /// Target platform override; `None` uses the `.dproj` default. + pub platform: Option, + /// Root of the Delphi installation (the folder containing `bin`). + pub installation_path: PathBuf, + /// Human-readable compiler name, echoed back in the result. + pub compiler_name: String, + /// Where to write the file; `None` writes `
.delphilsp.json` + /// next to the project. + pub out_path: Option, +} + +/// Generate (and write) the `.delphilsp.json` file for `request`. +pub fn generate(request: &GenerationRequest) -> Result { + let mut warnings: Vec = Vec::new(); + + let installation = &request.installation_path; + let rsvars_path = installation.join("bin").join("rsvars.bat"); + if !rsvars_path.exists() { + bail!("rsvars.bat not found in compiler installation: {}", rsvars_path.display()); + } + let rsvars = dproj_rs::rsvars::parse_rsvars_file(&rsvars_path) + .with_context(|| format!("Failed to parse {}", rsvars_path.display()))?; + + let bds_version = bds_version_from_installation(installation); + + // ── Build the macro map ──────────────────────────────────────────────── + let mut macros = MacroMap::new(); + macros.extend(rsvars); + // `rsvars.bat` deliberately blanks PLATFORM; the IDE's library paths use it. + macros.remove("PLATFORM"); + let bds = macros + .get("BDS") + .cloned() + .unwrap_or_else(|| installation.to_string_lossy().to_string()); + macros.set_default("BDS", bds.clone()); + macros.set_default("BDSLIB", format!("{bds}\\lib")); + macros.set_default("BDSINCLUDE", format!("{bds}\\include")); + if let Some(user_dir) = bds_user_dir(&bds_version) { + macros.set_default("BDSUSERDIR", user_dir); + } + // The IDE's own "Environment Variables" overrides win over rsvars/process env. + macros.extend(registry::read_ide_environment_variables(&bds_version)); + + // ── Resolve configuration / platform ─────────────────────────────────── + let dproj = match &request.dproj_path { + Some(path) => Some( + dproj_rs::DprojBuilder::new() + .env(macros.as_env()) + .from_file(path) + .map_err(|e| anyhow::anyhow!("Failed to parse {}: {e}", path.display()))?, + ), + _ => None, + }; + let configuration = request + .configuration + .clone() + .or_else(|| dproj.as_ref().and_then(|d| d.active_configuration().ok())) + .unwrap_or_else(|| "Debug".to_string()); + let platform = request + .platform + .clone() + .or_else(|| dproj.as_ref().and_then(|d| d.active_platform().ok())) + .unwrap_or_else(|| "Win32".to_string()); + macros.set("Platform", platform.clone()); + macros.set("Config", configuration.clone()); + macros.set("Configuration", configuration.clone()); + + // The IDE library settings are per-platform, so they can only be read once + // the effective platform is known. + let ide = registry::read_ide_library_settings(&bds_version, &platform); + if ide.search_path.is_none() { + warnings.push(format!( + "No IDE Library Path found in HKCU\\SOFTWARE\\Embarcadero\\BDS\\{bds_version}\\Library\\{platform}; \ + only the project's own search path will be used." + )); + } + + let global_search_paths = ide + .search_path + .as_deref() + .map(|raw| expand_path_list(raw, ¯os, &mut warnings)) + .unwrap_or_default(); + + // ── Evaluate the effective property group ────────────────────────────── + // `DCC_*` names are deliberately left out of the seed environment: the + // project's list properties end in an inheritance token (`;$(DCC_Define)`) + // which must chain across property groups. `dproj-rs` re-asserts every + // seeded variable after each group, so seeding those names would reset the + // chain. Left unseeded they expand to nothing, yielding the project's own + // values — the IDE's global counterparts are appended below. + let property_group = match &request.dproj_path { + Some(path) => Some( + dproj_rs::DprojBuilder::new() + .env(macros.as_env()) + .from_file(path) + .and_then(|d| d.active_property_group_for(&configuration, &platform)) + .map_err(|e| anyhow::anyhow!("Failed to evaluate {}: {e}", path.display()))?, + ), + _ => None, + }; + let dcc = property_group.as_ref().map(|pg| &pg.dcc_options); + + // ── Assemble the option payloads ─────────────────────────────────────── + let take = |value: Option<&String>| value.filter(|v| !v.trim().is_empty()).cloned(); + let flag_of = |value: Option<&String>| value.map(|v| v.eq_ignore_ascii_case("true")); + + // The IDE's global Library Path always follows the project's own entries — + // exactly where the `;$(DCC_UnitSearchPath)` inheritance token sat. + let mut search_paths = match dcc.and_then(|d| take(d.unit_search_path.as_ref())) { + Some(raw) => expand_path_list(&raw, ¯os, &mut warnings), + _ => Vec::new(), + }; + search_paths.extend(global_search_paths.iter().cloned()); + + let default_output = format!(".\\{platform}\\{configuration}"); + let exe_output = dcc + .and_then(|d| take(d.exe_output.as_ref())) + .unwrap_or_else(|| default_output.clone()); + let dcu_output = dcc + .and_then(|d| take(d.dcu_output.as_ref())) + .unwrap_or_else(|| default_output.clone()); + let bpl_output = dcc + .and_then(|d| take(d.bpl_output.as_ref())) + .or_else(|| ide.package_dpl_output.as_deref().map(|p| macros.expand(p))) + .unwrap_or_default(); + let dcp_output = dcc + .and_then(|d| take(d.dcp_output.as_ref())) + .or_else(|| ide.package_dcp_output.as_deref().map(|p| macros.expand(p))) + .unwrap_or_default(); + + let defines = strip_trailing_separators( + &dcc.and_then(|d| take(d.define.as_ref())).unwrap_or_default(), + ); + // Namespaces keep the IDE's trailing `;`. + let namespaces = dcc.and_then(|d| take(d.namespace.as_ref())).unwrap_or_default(); + // Project-defined aliases sit in front of the IDE's built-in ones, again + // where the `;$(DCC_UnitAlias)` inheritance token was. + let unit_aliases = match dcc.and_then(|d| take(d.unit_alias.as_ref())) { + Some(own) => format!("{};{DEFAULT_UNIT_ALIASES}", strip_trailing_separators(&own)), + _ => DEFAULT_UNIT_ALIASES.to_string(), + }; + + let is_package = request + .main_source + .extension() + .and_then(|e| e.to_str()) + .map(|e| e.eq_ignore_ascii_case("dpk")) + .unwrap_or(false); + let is_library = !is_package + && property_group + .as_ref() + .and_then(|pg| pg.project_properties.app_type.as_deref()) + .map(|t| t.eq_ignore_ascii_case("Library")) + .unwrap_or(false); + + // Debug DCUs are only meaningful when the configuration asks for them. + let wants_debug_dcus = dcc + .and_then(|d| flag_of(d.debug_dcus.as_ref())) + .unwrap_or_else(|| configuration.eq_ignore_ascii_case("Debug")); + let debug_dcu_path = match (wants_debug_dcus, ide.debug_dcu_path.as_deref()) { + (true, Some(raw)) => { + let expanded = macros.expand(raw); + if expanded.contains("$(") { + warnings.push(format!("Unresolved macro in IDE Debug DCU path: {expanded}")); + None + } else { + Some(trim_trailing_separator(&expanded).to_string()) + } + } + _ => None, + }; + + let required_packages = dproj + .as_ref() + .map(collect_required_packages) + .unwrap_or_default(); + + let options_input = DccOptionsInput { + is_package, + is_library, + optimize: dcc.and_then(|d| flag_of(d.optimize.as_ref())).unwrap_or(false), + stack_frames: dcc + .and_then(|d| flag_of(d.generate_stack_frames.as_ref())) + .unwrap_or(true), + inlining_off: dcc + .and_then(|d| d.inlining.as_deref()) + .map(|v| v.eq_ignore_ascii_case("off")) + .unwrap_or(false), + range_checking: dcc.and_then(|d| flag_of(d.range_checking.as_ref())), + overflow_checking: dcc.and_then(|d| flag_of(d.integer_overflow_check.as_ref())), + unit_aliases, + defines: defines.clone(), + namespaces, + exe_output, + dcu_output, + bpl_output, + dcp_output, + debug_dcu_path, + search_paths: search_paths.clone(), + required_packages, + description: dcc.and_then(|d| take(d.description.as_ref())), + }; + let dcc_options = build_dcc_options(&options_input); + + // ── dllname ──────────────────────────────────────────────────────────── + let dllname = match find_compiler_dll(installation, &platform) { + Some(name) => name, + _ => { + warnings.push(format!( + "No compiler DLL found for platform {platform} in {}\\bin; DelphiLSP may refuse to start.", + installation.display() + )); + String::new() + } + }; + + // ── Browsing paths and IDE data directories ──────────────────────────── + let browsing_paths: Vec = ide + .browsing_path + .as_deref() + .map(|raw| expand_path_list(raw, ¯os, &mut warnings)) + .unwrap_or_default() + .iter() + .map(|p| path_to_file_uri(Path::new(p))) + .collect(); + let common_app_data = dirs::config_dir() + .map(|dir| dir_to_file_uri(&dir.join("Embarcadero").join("BDS").join(&bds_version))) + .unwrap_or_default(); + let templates = dir_to_file_uri(&installation.join("ObjRepos")); + + // ── Write ────────────────────────────────────────────────────────────── + let project_uri = path_to_file_uri(&request.main_source); + let file = DelphiLspFile { + settings: DelphiLspSettings { + project: project_uri.clone(), + dllname: dllname.clone(), + dcc_options: dcc_options.clone(), + project_files: Vec::new(), + include_dcus_in_uses_completion: dcc + .and_then(|d| flag_of(d.include_dcus_in_uses_completion.as_ref())) + .unwrap_or(true), + enable_keyword_completion: true, + browsing_paths: browsing_paths.clone(), + common_app_data, + templates, + }, + generated_by: Some(GENERATED_BY_MARKER.to_string()), + }; + let out_path = match &request.out_path { + Some(path) => path.clone(), + _ => default_out_path(&request.main_source), + }; + if let Some(parent) = out_path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("Failed to create directory: {}", parent.display()))?; + } + let json = serde_json::to_string_pretty(&file)?; + std::fs::write(&out_path, json) + .with_context(|| format!("Failed to write {}", out_path.display()))?; + + let define_count = defines.split(';').filter(|d| !d.trim().is_empty()).count(); + Ok(DelphiLspConfigResult { + file_path: out_path.to_string_lossy().to_string(), + project_file: request + .dproj_path + .as_ref() + .unwrap_or(&request.main_source) + .to_string_lossy() + .to_string(), + project_uri, + dllname, + configuration, + platform, + compiler: request.compiler_name.clone(), + search_path_count: search_paths.len(), + browsing_path_count: browsing_paths.len(), + define_count, + warnings, + }) +} + +/// `\.delphilsp.json` next to the main source — the location the +/// DelphiLSP extension looks in. +pub fn default_out_path(main_source: &Path) -> PathBuf { + let stem = main_source + .file_stem() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| "project".to_string()); + let dir = main_source.parent().unwrap_or_else(|| Path::new(".")); + dir.join(format!("{stem}.delphilsp.json")) +} + +/// Trim trailing `;` separators left over after an inheritance token was +/// replaced by nothing (`DEBUG;QBF_ODAC;` → `DEBUG;QBF_ODAC`). +fn strip_trailing_separators(value: &str) -> String { + value.trim_end_matches(';').to_string() +} + +/// `.dcp` names referenced by the project — emitted as `-LU`. +fn collect_required_packages(dproj: &dproj_rs::Dproj) -> Vec { + dproj + .project + .item_groups + .iter() + .flat_map(|ig| &ig.dcc_references) + .filter_map(|r| { + let path = Path::new(&r.include); + let is_dcp = path + .extension() + .and_then(|e| e.to_str()) + .map(|e| e.eq_ignore_ascii_case("dcp")) + .unwrap_or(false); + is_dcp + .then(|| path.file_stem().map(|s| s.to_string_lossy().to_string())) + .flatten() + }) + .collect() +} + +/// `C:\…\Studio\23.0` → `23.0`. Falls back to the folder name as-is. +pub fn bds_version_from_installation(installation: &Path) -> String { + installation + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default() +} + +/// `\Embarcadero\Studio\` — the IDE's `$(BDSUSERDIR)`. +fn bds_user_dir(bds_version: &str) -> Option { + let documents = dirs::document_dir()?; + Some( + documents + .join("Embarcadero") + .join("Studio") + .join(bds_version) + .to_string_lossy() + .to_string(), + ) +} + +/// dcc DLL file-name prefix (and optional suffix) for a target platform. +fn compiler_dll_prefix(platform: &str) -> (&'static str, &'static str) { + match platform.to_ascii_lowercase().as_str() { + "win32" => ("dcc32", ""), + "win64" => ("dcc64", ""), + "win64x" => ("dcc64", "N"), + "android" => ("dccaarm", ""), + "android64" => ("dccaarm64", ""), + "iosdevice64" => ("dcciosarm64", ""), + "iossimarm64" => ("dcciossimarm64", ""), + "linux64" => ("dcclinux64", ""), + "osx64" => ("dccosx64", ""), + "osxarm64" => ("dccosxarm64", ""), + _ => ("dcc32", ""), + } +} + +/// Locate the compiler DLL DelphiLSP must load, e.g. `dcc64290.dll`, by +/// scanning `\bin` for `.dll`. +pub fn find_compiler_dll(installation: &Path, platform: &str) -> Option { + let (prefix, suffix) = compiler_dll_prefix(platform); + let entries = std::fs::read_dir(installation.join("bin")).ok()?; + let mut matches: Vec = entries + .filter_map(|entry| entry.ok()) + .filter_map(|entry| entry.file_name().to_str().map(|s| s.to_string())) + .filter(|name| matches_compiler_dll(name, prefix, suffix)) + .collect(); + matches.sort(); + matches.pop() +} + +/// `dcc64290.dll` matches (`dcc64`, ``) but `dcc64290N.dll` does not — the +/// middle section must be digits only. +fn matches_compiler_dll(name: &str, prefix: &str, suffix: &str) -> bool { + let lower = name.to_ascii_lowercase(); + let Some(stem) = lower.strip_suffix(".dll") else { + return false; + }; + let Some(rest) = stem.strip_prefix(&prefix.to_ascii_lowercase()) else { + return false; + }; + let Some(digits) = rest.strip_suffix(&suffix.to_ascii_lowercase()) else { + return false; + }; + !digits.is_empty() && digits.chars().all(|c| c.is_ascii_digit()) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_macros() -> MacroMap { + let mut macros = MacroMap::new(); + macros.set("BDS", r"c:\delphi\23.0"); + macros.set("BDSLIB", r"c:\delphi\23.0\lib"); + macros.set("BDSCOMMONDIR", r"c:\public\23.0"); + macros.set("Platform", "Win64"); + macros.set("Config", "Debug"); + macros + } + + // ── Macro expansion ─────────────────────────────────────────────────── + + #[test] + fn expands_case_insensitively() { + let macros = sample_macros(); + assert_eq!( + macros.expand(r"$(BDSLIB)\$(PLATFORM)\release"), + r"c:\delphi\23.0\lib\Win64\release" + ); + } + + #[test] + fn expands_nested_values() { + let mut macros = MacroMap::new(); + macros.set("ROOT", r"c:\root"); + macros.set("SUB", r"$(ROOT)\sub"); + assert_eq!(macros.expand(r"$(SUB)\leaf"), r"c:\root\sub\leaf"); + } + + #[test] + fn leaves_unknown_macros_verbatim() { + let macros = sample_macros(); + assert_eq!(macros.expand(r"$(NOPE)\x"), r"$(NOPE)\x"); + } + + #[test] + fn self_referential_macro_terminates() { + let mut macros = MacroMap::new(); + macros.set("LOOP", "$(LOOP)"); + assert_eq!(macros.expand("$(LOOP)"), "$(LOOP)"); + } + + // ── Path list expansion ─────────────────────────────────────────────── + + #[test] + fn drops_entries_with_unresolved_macros_and_warns() { + let macros = sample_macros(); + let mut warnings = Vec::new(); + let paths = expand_path_list( + r"$(BDS)\include\;$(VEGADIR)\src; ;c:\lib", + ¯os, + &mut warnings, + ); + assert_eq!(paths, vec![r"c:\delphi\23.0\include", r"c:\lib"]); + assert_eq!(warnings.len(), 1); + assert!(warnings[0].contains("$(VEGADIR)"), "{}", warnings[0]); + } + + #[test] + fn keeps_drive_root_intact() { + assert_eq!(trim_trailing_separator(r"c:\"), r"c:\"); + assert_eq!(trim_trailing_separator(r"c:\foo\"), r"c:\foo"); + } + + // ── File URI ────────────────────────────────────────────────────────── + + #[test] + fn encodes_windows_path_as_file_uri() { + assert_eq!( + path_to_file_uri(Path::new(r"C:\Athens\hydra_2\About\libAboutD29.dpk")), + "file:///C%3A/Athens/hydra_2/About/libAboutD29.dpk" + ); + } + + #[test] + fn encodes_spaces_parens_and_plus_in_file_uri() { + assert_eq!( + path_to_file_uri(Path::new(r"C:\Program Files (x86)\GDI+\a.dpr")), + "file:///C%3A/Program%20Files%20%28x86%29/GDI%2B/a.dpr" + ); + } + + #[test] + fn directory_uris_end_with_a_slash() { + assert_eq!(dir_to_file_uri(Path::new(r"C:\a\ObjRepos")), "file:///C%3A/a/ObjRepos/"); + assert_eq!(dir_to_file_uri(Path::new(r"C:\a\ObjRepos\")), "file:///C%3A/a/ObjRepos/"); + } + + // ── dccOptions assembly ─────────────────────────────────────────────── + + fn package_input() -> DccOptionsInput { + DccOptionsInput { + is_package: true, + optimize: false, + stack_frames: true, + inlining_off: true, + range_checking: Some(true), + unit_aliases: DEFAULT_UNIT_ALIASES.to_string(), + defines: "DEBUG;QBF_ODAC".to_string(), + namespaces: "System;Vcl;".to_string(), + exe_output: r".\Win64\Debug".to_string(), + dcu_output: r".\Win64\Debug".to_string(), + bpl_output: r"c:\public\23.0\Bpl\Win64".to_string(), + dcp_output: r"c:\public\23.0\Dcp\Win64".to_string(), + debug_dcu_path: Some(r"c:\delphi\23.0\lib\Win64\debug".to_string()), + search_paths: vec![ + r".\Win64\Debug".to_string(), + r"c:\delphi\23.0\lib\Win64\release".to_string(), + r"c:\libs\Common Library\Sources".to_string(), + ], + required_packages: vec!["libStdFormsD29".to_string()], + description: Some("Hydra About Menu".to_string()), + ..Default::default() + } + } + + #[test] + fn assembles_switches_in_ide_order() { + let options = build_dcc_options(&package_input()); + assert!( + options.starts_with("-$O- -$W+ --inline:off -$R+ --no-config -Q -Z -TX.bpl -A"), + "{options}" + ); + } + + #[test] + fn emits_defines_namespaces_and_outputs() { + let options = build_dcc_options(&package_input()); + assert!(options.contains(" -DDEBUG;QBF_ODAC "), "{options}"); + assert!(options.contains(" -NSSystem;Vcl; "), "{options}"); + assert!(options.contains(r" -E.\Win64\Debug "), "{options}"); + assert!(options.contains(r" -NU.\Win64\Debug "), "{options}"); + assert!(options.contains(r" -LEc:\public\23.0\Bpl\Win64 "), "{options}"); + assert!(options.contains(r" -LNc:\public\23.0\Dcp\Win64 "), "{options}"); + } + + #[test] + fn include_and_unit_paths_lead_with_debug_dcus() { + let options = build_dcc_options(&package_input()); + let unit = options + .split(" -U") + .nth(1) + .expect("missing -U") + .to_string(); + assert!(unit.starts_with(r"c:\delphi\23.0\lib\Win64\debug;.\Win64\Debug;"), "{unit}"); + let object = options.split(" -O").nth(1).expect("missing -O"); + assert!(object.starts_with(r".\Win64\Debug;"), "{object}"); + } + + #[test] + fn quotes_only_entries_containing_spaces() { + let options = build_dcc_options(&package_input()); + assert!(options.contains(r#";"c:\libs\Common Library\Sources""#), "{options}"); + assert!(!options.contains(r#""c:\libs\Common""#), "{options}"); + } + + #[test] + fn emits_description_and_required_packages_last() { + let options = build_dcc_options(&package_input()); + assert!(options.ends_with(r#"--description:"Hydra About Menu" -LUlibStdFormsD29;"#), "{options}"); + } + + #[test] + fn program_target_uses_exe_extension() { + let input = DccOptionsInput { is_package: false, ..package_input() }; + assert!(build_dcc_options(&input).contains(" -TX.exe ")); + let library = DccOptionsInput { is_package: false, is_library: true, ..package_input() }; + assert!(build_dcc_options(&library).contains(" -TX.dll ")); + } + + #[test] + fn omits_optional_switches_when_unknown() { + let input = DccOptionsInput { + range_checking: None, + overflow_checking: None, + inlining_off: false, + ..package_input() + }; + let options = build_dcc_options(&input); + assert!(!options.contains("-$R"), "{options}"); + assert!(!options.contains("-$Q"), "{options}"); + assert!(!options.contains("--inline:off"), "{options}"); + } + + // ── Misc helpers ────────────────────────────────────────────────────── + + #[test] + fn compiler_dll_name_must_end_in_digits() { + assert!(matches_compiler_dll("dcc64290.dll", "dcc64", "")); + assert!(!matches_compiler_dll("dcc64290N.dll", "dcc64", "")); + assert!(matches_compiler_dll("dcc64290N.dll", "dcc64", "N")); + assert!(!matches_compiler_dll("dcc64.dll", "dcc64", "")); + assert!(!matches_compiler_dll("dcc32290.dll", "dcc64", "")); + } + + #[test] + fn default_out_path_sits_next_to_the_main_source() { + assert_eq!( + default_out_path(Path::new(r"C:\a\b\libAboutD29.dpk")), + PathBuf::from(r"C:\a\b\libAboutD29.delphilsp.json") + ); + } + + #[test] + fn generated_by_marker_sits_beside_settings_not_inside_it() { + let file = DelphiLspFile { + settings: DelphiLspSettings { + project: "file:///C%3A/a/App.dpr".into(), + dllname: "dcc32290.dll".into(), + dcc_options: "--no-config".into(), + project_files: Vec::new(), + include_dcus_in_uses_completion: true, + enable_keyword_completion: true, + browsing_paths: Vec::new(), + common_app_data: String::new(), + templates: String::new(), + }, + generated_by: Some(GENERATED_BY_MARKER.to_string()), + }; + let json: serde_json::Value = + serde_json::from_str(&serde_json::to_string(&file).unwrap()).unwrap(); + assert_eq!(json["generatedBy"], "delphi-devkit"); + assert!(json["settings"].get("generatedBy").is_none(), "marker must not be nested"); + } + + #[test] + fn ide_files_without_the_marker_still_parse() { + // The IDE writes `settings` only; reading such a file must not fail and + // must leave the ownership marker unset. + let raw = r#"{"settings":{"project":"file:///C%3A/a/App.dpr","dllname":"dcc32290.dll", + "dccOptions":"--no-config","projectFiles":[],"includeDCUsInUsesCompletion":true, + "enableKeyWordCompletion":true,"browsingPaths":[],"CommonAppData":"","Templates":""}}"#; + let file: DelphiLspFile = serde_json::from_str(raw).unwrap(); + assert_eq!(file.generated_by, None); + } + + #[test] + fn strips_trailing_define_separators() { + assert_eq!(strip_trailing_separators("DEBUG;QBF_ODAC;"), "DEBUG;QBF_ODAC"); + assert_eq!(strip_trailing_separators("DEBUG"), "DEBUG"); + } +} diff --git a/core/src/delphilsp/registry.rs b/core/src/delphilsp/registry.rs new file mode 100644 index 0000000..4ad89d5 --- /dev/null +++ b/core/src/delphilsp/registry.rs @@ -0,0 +1,88 @@ +//! Read the RAD Studio IDE's per-user settings that a `.dproj` alone cannot +//! provide: the **global Library Path** and the user-defined **environment +//! variable** overrides (`$(VEGADIR)`, `$(DXVCL)`, …). +//! +//! Both live under `HKCU\SOFTWARE\Embarcadero\BDS\`. Everything here +//! degrades gracefully: a missing key yields empty data plus a warning from +//! the caller rather than an error, and non-Windows builds compile to stubs. + +/// The IDE's library settings for one target platform. +#[derive(Debug, Clone, Default)] +pub struct IdeLibrarySettings { + /// `Search Path` — the global Library Path, `;`-separated and still + /// containing `$(NAME)` macros. + pub search_path: Option, + /// `Browsing Path` — the directories DelphiLSP may navigate into + /// (RTL/VCL sources), `;`-separated and still containing `$(NAME)` macros. + pub browsing_path: Option, + /// `Debug DCU Path` — prepended to `-I`/`-U` for debug configurations. + pub debug_dcu_path: Option, + /// `Package DPL Output` — the default `-LE` target. + pub package_dpl_output: Option, + /// `Package DCP Output` — the default `-LN` target. + pub package_dcp_output: Option, +} + +#[cfg(windows)] +mod imp { + use super::IdeLibrarySettings; + use winreg::RegKey; + use winreg::enums::{HKEY_CURRENT_USER, KEY_READ, REG_EXPAND_SZ, REG_SZ}; + + fn bds_key(bds_version: &str, sub_key: &str) -> Option { + let path = format!("SOFTWARE\\Embarcadero\\BDS\\{bds_version}\\{sub_key}"); + RegKey::predef(HKEY_CURRENT_USER) + .open_subkey_with_flags(path, KEY_READ) + .ok() + } + + fn string_value(key: &RegKey, name: &str) -> Option { + key.get_value::(name) + .ok() + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + } + + pub fn read_ide_library_settings(bds_version: &str, platform: &str) -> IdeLibrarySettings { + let Some(key) = bds_key(bds_version, &format!("Library\\{platform}")) else { + return IdeLibrarySettings::default(); + }; + IdeLibrarySettings { + search_path: string_value(&key, "Search Path"), + browsing_path: string_value(&key, "Browsing Path"), + debug_dcu_path: string_value(&key, "Debug DCU Path"), + package_dpl_output: string_value(&key, "Package DPL Output"), + package_dcp_output: string_value(&key, "Package DCP Output"), + } + } + + pub fn read_ide_environment_variables(bds_version: &str) -> Vec<(String, String)> { + let Some(key) = bds_key(bds_version, "Environment Variables") else { + return Vec::new(); + }; + key.enum_values() + .filter_map(|entry| entry.ok()) + // Only string values define a usable `$(NAME)` macro. + .filter(|(_, value)| matches!(value.vtype, REG_SZ | REG_EXPAND_SZ)) + .filter_map(|(name, value)| { + let text = value.to_string().trim().to_string(); + (!name.trim().is_empty() && !text.is_empty()).then_some((name, text)) + }) + .collect() + } +} + +#[cfg(not(windows))] +mod imp { + use super::IdeLibrarySettings; + + pub fn read_ide_library_settings(_bds_version: &str, _platform: &str) -> IdeLibrarySettings { + IdeLibrarySettings::default() + } + + pub fn read_ide_environment_variables(_bds_version: &str) -> Vec<(String, String)> { + Vec::new() + } +} + +pub use imp::{read_ide_environment_variables, read_ide_library_settings}; diff --git a/core/src/lib.rs b/core/src/lib.rs index 816895f..9a3e3a6 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -1,4 +1,5 @@ pub mod commands; +pub mod delphilsp; pub mod projects; pub mod lexorank; pub mod lsp_types; diff --git a/core/src/lsp_types.rs b/core/src/lsp_types.rs index 72deccb..7c8de7f 100644 --- a/core/src/lsp_types.rs +++ b/core/src/lsp_types.rs @@ -291,6 +291,21 @@ pub struct DprojMetadataResponse { pub active_platform: String, } +/// Request params for `delphilsp/generate` – asks the server to (re)write the +/// `.delphilsp.json` settings file consumed by Embarcadero's DelphiLSP +/// extension. All fields are optional and mirror `cmd_delphilsp_config`. +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(default)] +pub struct DelphiLspGenerateParams { + /// Project ID, project name, or path to a `.dproj`/`.dpr`/`.dpk`. + /// Omit to use the currently active project. + pub project: Option, + /// Compiler key or product name for a file that belongs to no workspace. + pub compiler: Option, + /// Destination override; defaults to `
.delphilsp.json`. + pub out: Option, +} + #[derive(Debug, Clone, Deserialize, Serialize)] pub struct CustomDocumentFormat { pub content: String, diff --git a/core/tests/commands.rs b/core/tests/commands.rs index f8c6621..aa6a707 100644 --- a/core/tests/commands.rs +++ b/core/tests/commands.rs @@ -459,3 +459,52 @@ fn ambiguous_projects_display_matches_spec() { assert!(display.contains("- ID 123 = Workspace 1 - be (path\\to\\be.dpr)")); assert!(display.contains("- ID 124 = Workspace 2 - be")); } + +// ═══════════════════════════════════════════════════════════════════════════════ +// DelphiLspConfigResult +// ═══════════════════════════════════════════════════════════════════════════════ + +fn delphilsp_result() -> ddk_core::delphilsp::DelphiLspConfigResult { + ddk_core::delphilsp::DelphiLspConfigResult { + file_path: r"C:\proj\App.delphilsp.json".into(), + project_file: r"C:\proj\App.dproj".into(), + project_uri: "file:///C%3A/proj/App.dpr".into(), + dllname: "dcc64290.dll".into(), + configuration: "Debug".into(), + platform: "Win64".into(), + compiler: "Delphi 12.0 Athens".into(), + search_path_count: 42, + browsing_path_count: 7, + define_count: 2, + warnings: Vec::new(), + } +} + +#[test] +fn delphilsp_result_display_summarises_the_written_file() { + let display = format!("{}", delphilsp_result()); + assert!(display.contains(r"Wrote C:\proj\App.delphilsp.json"), "{display}"); + assert!(display.contains("Debug / Win64"), "{display}"); + assert!(display.contains("Delphi 12.0 Athens (dcc64290.dll)"), "{display}"); + assert!(display.contains("42 entries, 7 browsing paths, 2 defines"), "{display}"); +} + +#[test] +fn delphilsp_result_display_lists_warnings() { + let mut result = delphilsp_result(); + result.warnings.push("Dropped search-path entry with unresolved macro: $(NOPE)".into()); + let display = format!("{result}"); + assert!(display.contains("! Dropped search-path entry with unresolved macro: $(NOPE)"), "{display}"); +} + +#[test] +fn delphilsp_result_serialises_every_field() { + let json = serde_json::to_value(delphilsp_result()).unwrap(); + for key in [ + "file_path", "project_file", "project_uri", "dllname", "configuration", + "platform", "compiler", "search_path_count", "browsing_path_count", + "define_count", "warnings", + ] { + assert!(json.get(key).is_some(), "missing {key} in {json}"); + } +} diff --git a/core/tests/delphilsp.rs b/core/tests/delphilsp.rs new file mode 100644 index 0000000..5f6ffb7 --- /dev/null +++ b/core/tests/delphilsp.rs @@ -0,0 +1,325 @@ +//! End-to-end generation of a `.delphilsp.json` from a synthetic project and a +//! fake Delphi installation. +//! +//! The IDE's global Library Path comes from the machine's registry, so the +//! assertions here deliberately cover only what the inputs determine: the +//! project's own search-path entries, defines, namespaces, outputs, target +//! kind, and the resolved compiler DLL. + +use std::path::{Path, PathBuf}; + +use ddk_core::delphilsp::{DccOptionsInput, GenerationRequest, build_dcc_options, generate}; + +/// Minimal but realistic `.dproj`: two configurations, a platform-specific +/// group, and the `;$(DCC_…)` inheritance tokens Delphi writes. +const DPROJ: &str = r#" + + App.dpr + True + Debug + Win64 + App + Application + + + true + + + true + Base + true + + + true + Base + true + + + true + Base + true + + + .\$(Platform)\$(Config);..\shared;$(DCC_UnitSearchPath) + .\$(Platform)\$(Config) + .\$(Platform)\$(Config) + System;Vcl;$(DCC_Namespace) + APPWIDE;$(DCC_Define) + false + true + true + + + Winapi;$(DCC_Namespace) + + + RELEASE;$(DCC_Define) + true + + + DEBUG;$(DCC_Define) + off + true + + + MainSource + + + Base + Cfg_1Base + Cfg_2Base + + +"#; + +/// A directory tree that looks enough like a Delphi install for the generator: +/// `bin\rsvars.bat` plus the two Windows compiler DLLs. +fn fake_installation(root: &Path) -> PathBuf { + let installation = root.join("Studio").join("23.0"); + let bin = installation.join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + std::fs::write( + bin.join("rsvars.bat"), + format!( + "@SET BDS={}\r\n@SET BDSCOMMONDIR={}\r\n@SET PLATFORM=\r\n", + installation.display(), + root.join("Public").display() + ), + ) + .unwrap(); + std::fs::write(bin.join("dcc32290.dll"), b"").unwrap(); + std::fs::write(bin.join("dcc64290.dll"), b"").unwrap(); + std::fs::write(bin.join("dcc64290N.dll"), b"").unwrap(); + installation +} + +struct Fixture { + _temp: tempfile::TempDir, + request: GenerationRequest, + out_path: PathBuf, +} + +fn fixture(configuration: Option<&str>, platform: Option<&str>) -> Fixture { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + let project_dir = root.join("proj"); + std::fs::create_dir_all(&project_dir).unwrap(); + let dproj_path = project_dir.join("App.dproj"); + std::fs::write(&dproj_path, DPROJ).unwrap(); + std::fs::write(project_dir.join("App.dpr"), "program App; begin end.").unwrap(); + let installation = fake_installation(root); + let out_path = project_dir.join("App.delphilsp.json"); + Fixture { + request: GenerationRequest { + dproj_path: Some(dproj_path), + main_source: project_dir.join("App.dpr"), + configuration: configuration.map(|s| s.to_string()), + platform: platform.map(|s| s.to_string()), + installation_path: installation, + compiler_name: "Delphi 12.0 Athens".to_string(), + out_path: Some(out_path.clone()), + }, + out_path, + _temp: temp, + } +} + +fn written_file(fixture: &Fixture) -> serde_json::Value { + let raw = std::fs::read_to_string(&fixture.out_path).unwrap(); + serde_json::from_str::(&raw).unwrap() +} + +fn written_settings(fixture: &Fixture) -> serde_json::Value { + written_file(fixture)["settings"].clone() +} + +/// Split a dcc path option (`-U…`) into its individual entries, unquoting +/// those the generator wrapped because they contain spaces. +fn path_entries(options: &str, prefix: &str) -> Vec { + let start = options.find(prefix).expect("option not found"); + let tail = &options[start + prefix.len()..]; + let mut entries = Vec::new(); + let mut current = String::new(); + let mut quoted = false; + for ch in tail.chars() { + match ch { + '"' => quoted = !quoted, + ' ' if !quoted => break, + ';' if !quoted => entries.push(std::mem::take(&mut current)), + _ => current.push(ch), + } + } + if !current.is_empty() { + entries.push(current); + } + entries +} + +#[test] +fn writes_the_full_ide_key_set() { + let fixture = fixture(None, None); + generate(&fixture.request).unwrap(); + let settings = written_settings(&fixture); + for key in [ + "project", + "dllname", + "dccOptions", + "projectFiles", + "includeDCUsInUsesCompletion", + "enableKeyWordCompletion", + "browsingPaths", + "CommonAppData", + "Templates", + ] { + assert!(settings.get(key).is_some(), "missing {key}"); + } +} + +#[test] +fn stamps_the_ownership_marker_next_to_settings() { + let fixture = fixture(None, None); + generate(&fixture.request).unwrap(); + let file = written_file(&fixture); + + // The VS Code extension auto-refreshes a stale config only when it finds + // this marker, so an IDE-written file (which has none) is never clobbered. + assert_eq!(file["generatedBy"], ddk_core::delphilsp::GENERATED_BY_MARKER); + assert_eq!(file["generatedBy"], "delphi-devkit"); + assert!( + file["settings"].get("generatedBy").is_none(), + "the marker belongs beside `settings`, not inside it" + ); + assert_eq!( + file.as_object().unwrap().len(), + 2, + "expected exactly `settings` and `generatedBy`: {file}" + ); +} + +#[test] +fn regenerating_over_a_ddk_file_keeps_the_marker() { + let fixture = fixture(None, None); + generate(&fixture.request).unwrap(); + generate(&fixture.request).unwrap(); + assert_eq!(written_file(&fixture)["generatedBy"], "delphi-devkit"); +} + +#[test] +fn project_uri_points_at_the_main_source_not_the_dproj() { + let fixture = fixture(None, None); + let result = generate(&fixture.request).unwrap(); + assert!(result.project_uri.starts_with("file:///"), "{}", result.project_uri); + assert!(result.project_uri.ends_with("/App.dpr"), "{}", result.project_uri); + let path_part = result.project_uri.trim_start_matches("file:///"); + assert!(!path_part.contains(':'), "drive colon must be encoded: {}", result.project_uri); + assert!(path_part.contains("%3A"), "{}", result.project_uri); +} + +#[test] +fn resolves_the_platform_specific_compiler_dll() { + let win64 = fixture(None, Some("Win64")); + assert_eq!(generate(&win64.request).unwrap().dllname, "dcc64290.dll"); + let win32 = fixture(None, Some("Win32")); + assert_eq!(generate(&win32.request).unwrap().dllname, "dcc32290.dll"); +} + +#[test] +fn debug_configuration_chains_defines_across_property_groups() { + let fixture = fixture(Some("Debug"), Some("Win64")); + let result = generate(&fixture.request).unwrap(); + let options = written_settings(&fixture)["dccOptions"].as_str().unwrap().to_string(); + assert!(options.contains(" -DDEBUG;APPWIDE "), "{options}"); + assert_eq!(result.define_count, 2); +} + +#[test] +fn release_configuration_selects_its_own_defines_and_optimisation() { + let fixture = fixture(Some("Release"), Some("Win64")); + generate(&fixture.request).unwrap(); + let options = written_settings(&fixture)["dccOptions"].as_str().unwrap().to_string(); + assert!(options.contains(" -DRELEASE;APPWIDE "), "{options}"); + assert!(options.starts_with("-$O+ "), "{options}"); + assert!(!options.contains("--inline:off"), "{options}"); +} + +#[test] +fn namespaces_chain_platform_group_first() { + let fixture = fixture(Some("Debug"), Some("Win64")); + generate(&fixture.request).unwrap(); + let options = written_settings(&fixture)["dccOptions"].as_str().unwrap().to_string(); + assert!(options.contains(" -NSWinapi;System;Vcl; "), "{options}"); +} + +#[test] +fn project_search_paths_come_first_and_keep_their_relative_form() { + let fixture = fixture(Some("Debug"), Some("Win64")); + let result = generate(&fixture.request).unwrap(); + let options = written_settings(&fixture)["dccOptions"].as_str().unwrap().to_string(); + + let unit_paths = path_entries(&options, " -U"); + // Debug pulls the IDE's debug-DCU directory in front when the registry + // provides one; the project's own entries always follow immediately. + let project_entries: Vec<&String> = unit_paths + .iter() + .filter(|p| p.as_str() == r".\Win64\Debug" || p.as_str() == r"..\shared") + .collect(); + assert_eq!(project_entries.len(), 2, "{unit_paths:?}"); + let first = unit_paths.iter().position(|p| p == r".\Win64\Debug").unwrap(); + assert_eq!(unit_paths[first + 1], r"..\shared", "{unit_paths:?}"); + + // -O / -R never carry the debug-DCU entry, so they start at the project's own path. + assert_eq!(path_entries(&options, " -O")[0], r".\Win64\Debug"); + assert_eq!(path_entries(&options, " -R")[0], r".\Win64\Debug"); + assert!(result.search_path_count >= 2); +} + +#[test] +fn outputs_and_target_kind_follow_the_dproj() { + let fixture = fixture(Some("Debug"), Some("Win64")); + generate(&fixture.request).unwrap(); + let options = written_settings(&fixture)["dccOptions"].as_str().unwrap().to_string(); + assert!(options.contains(" -TX.exe "), "{options}"); + assert!(options.contains(r" -E.\Win64\Debug "), "{options}"); + assert!(options.contains(r" -NU.\Win64\Debug "), "{options}"); + // rtl.dcp is a package reference; Unit1.pas is not. + assert!(options.ends_with(" -LUrtl;"), "{options}"); +} + +#[test] +fn always_emits_the_switches_delphilsp_requires() { + let fixture = fixture(None, None); + generate(&fixture.request).unwrap(); + let options = written_settings(&fixture)["dccOptions"].as_str().unwrap().to_string(); + for switch in ["--no-config", " -Q ", " -Z "] { + assert!(options.contains(switch), "missing {switch} in {options}"); + } +} + +#[test] +fn missing_installation_is_reported_not_panicked() { + let mut fixture = fixture(None, None); + fixture.request.installation_path = fixture.request.installation_path.join("nope"); + let error = generate(&fixture.request).unwrap_err().to_string(); + assert!(error.contains("rsvars.bat"), "{error}"); +} + +#[test] +fn builder_output_is_stable_for_a_minimal_input() { + let options = build_dcc_options(&DccOptionsInput { + stack_frames: true, + unit_aliases: "A=B".into(), + defines: "DEBUG".into(), + namespaces: "System;".into(), + exe_output: r".\Win32\Debug".into(), + dcu_output: r".\Win32\Debug".into(), + search_paths: vec![r"c:\lib".into()], + ..Default::default() + }); + assert_eq!( + options, + concat!( + r"-$O- -$W+ --no-config -Q -Z -TX.exe -AA=B -DDEBUG -E.\Win32\Debug ", + r"-Ic:\lib -NU.\Win32\Debug -NSSystem; -Oc:\lib -Rc:\lib -Uc:\lib" + ) + ); +} diff --git a/mcp/src/handler.rs b/mcp/src/handler.rs index 525d5f5..0d32c0b 100644 --- a/mcp/src/handler.rs +++ b/mcp/src/handler.rs @@ -237,6 +237,34 @@ pub struct FormatFileArgs { pub encoding: Option, } +#[macros::mcp_tool( + name = "delphi_generate_delphilsp_config", + description = "Generates the `.delphilsp.json` settings file that Embarcadero's \ + DelphiLSP VS Code extension needs for Delphi code insight (completion, go-to-definition), \ + so it works without ever opening the RAD Studio IDE. \ + The file is reconstructed from the project's .dproj, the compiler installation's rsvars.bat, \ + and the IDE's global Library Path + environment-variable overrides from the registry. \ + Target it with `project`: a numeric ID, a project name, or a path to a .dproj/.dpr/.dpk. \ + A name matching several projects returns the candidate list instead of writing anything. \ + Omit `project` to use the currently active project. \ + A path that belongs to no workspace is handled ad-hoc: pick its compiler with `compiler` \ + (an exact key like \"12.0\" or a product name like \"Delphi 12\"; default: newest installed). \ + `out` overrides the destination, which is otherwise `
.delphilsp.json` \ + next to the project. Re-run it after changing the project's search paths, defines, \ + configuration or platform." +)] +#[derive(Debug, Deserialize, Serialize, macros::JsonSchema)] +pub struct GenerateDelphiLspConfigArgs { + /// Project to describe: a numeric ID, a project name, or a path to a + /// .dproj/.dpr/.dpk. Omit to use the currently active project. + pub project: Option, + /// Compiler key (e.g. "12.0") or product name (e.g. "Delphi 12"), used only + /// for a file path that belongs to no workspace. Optional. + pub compiler: Option, + /// Write the settings file here instead of next to the project's main source. Optional. + pub out: Option, +} + rust_mcp_sdk::tool_box!(DdkTools, [ GetDdkExtensionInfoArgs, GetEnvironmentInfoArgs, @@ -251,6 +279,7 @@ rust_mcp_sdk::tool_box!(DdkTools, [ AddProjectArgs, AddWorkspaceArgs, FormatFileArgs, + GenerateDelphiLspConfigArgs, ]); // --------------------------------------------------------------------------- @@ -295,6 +324,7 @@ impl ServerHandler for DdkMcpHandler { "delphi_add_project" => add_project(&args).await, "delphi_add_workspace" => add_workspace(&args).await, "delphi_format_file" => format_file(&args).await, + "delphi_generate_delphilsp_config" => generate_delphilsp_config(&args).await, _ => format!("Unknown tool: {name}"), }; Ok(CallToolResult::text_content(vec![TextContent::from(result_text)])) @@ -468,6 +498,21 @@ async fn add_workspace(args: &Value) -> String { } } +async fn generate_delphilsp_config(args: &Value) -> String { + let string_arg = |key: &str| args.get(key).and_then(|v| v.as_str()).map(|s| s.to_string()); + match commands::cmd_delphilsp_config( + string_arg("project"), + string_arg("compiler"), + string_arg("out"), + ) + .await + { + Ok(commands::DelphiLspOrAmbiguity::Output(result)) => result.to_string(), + Ok(commands::DelphiLspOrAmbiguity::Ambiguity(amb)) => amb.to_string(), + Err(e) => format!("{e}"), + } +} + async fn format_file(args: &Value) -> String { let file_path = match args.get("file_path").and_then(|v| v.as_str()) { Some(p) => p.to_string(), diff --git a/server/src/main.rs b/server/src/main.rs index 24c5db8..b56e4b8 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -87,6 +87,31 @@ impl DelphiLsp { }); } + /// Thin wrapper over `commands::cmd_delphilsp_config` so the VS Code + /// extension can regenerate a project's DelphiLSP settings file without + /// shelling out to the CLI. + async fn delphilsp_generate( + &self, + params: DelphiLspGenerateParams, + ) -> tower_lsp::jsonrpc::Result { + use ddk_core::commands::{DelphiLspOrAmbiguity, cmd_delphilsp_config}; + match cmd_delphilsp_config(params.project, params.compiler, params.out).await { + Ok(DelphiLspOrAmbiguity::Output(result)) => { + lsp_info!(self.client, "Wrote DelphiLSP settings: {}", result.file_path); + Ok(result) + } + Ok(DelphiLspOrAmbiguity::Ambiguity(ambiguity)) => { + Err(jsonrpc::Error::invalid_params(ambiguity.to_string())) + } + Err(error) => { + lsp_error!(self.client, "Failed to generate DelphiLSP settings: {}", error); + Err(jsonrpc::Error::invalid_params(format!( + "Failed to generate DelphiLSP settings: {error}" + ))) + } + } + } + async fn dproj_metadata( &self, params: DprojMetadataParams, @@ -225,6 +250,7 @@ async fn main() -> Result<()> { .custom_method("custom/document/format", DelphiLsp::custom_document_format) .custom_method("notifications/settings/encoding", DelphiLsp::settings_encoding) .custom_method("dproj/metadata", DelphiLsp::dproj_metadata) + .custom_method("delphilsp/generate", DelphiLsp::delphilsp_generate) .finish(); Server::new(stdin(), stdout(), socket).serve(service).await; From 886aa06194045a70d0bfe7d046708d132761984d Mon Sep 17 00:00:00 2001 From: csm101 Date: Sat, 25 Jul 2026 14:34:02 +0200 Subject: [PATCH 2/6] feat(vscode): keep DelphiLSP synced with the active project and re-validate open files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When Embarcadero's DelphiLSP extension is installed (and only then — the feature is gated on its presence and hot-enables via extensions.onDidChange), DDK now keeps DelphiLSP's settingsFile pointed at the DDK active project: switching projects updates delphiLsp.settingsFile, generating the project's .delphilsp.json on the fly when it is missing, or regenerating it when it is DDK-owned (generatedBy marker) and older than its .dproj. IDE-generated files are used as-is and never rewritten. A Generate DelphiLSP Config context-menu action covers the explicit case. DelphiLSP's server applies a settings change only to files opened afterwards: already-open files keep their stale diagnostics until edited (its own Select project settings command has the same limitation, and — verified via the server's raw LSP logs — even a full server restart replays didOpen before the settings arrive, so neither helps). After the switch DDK therefore re-emits didOpen for every open Delphi document through a language-mode round-trip (objectpascal -> plaintext -> back), which re-validates it under the new project while leaving tabs, focus, cursor, dirty state and undo history untouched. New settings: ddk.delphilsp.autoSync and ddk.delphilsp.revalidateOpenFilesOnSwitch, both enabled by default. All extension logic is isolated in src/delphilsp/ with a one-line hookup in runtime.ts. --- CHANGELOG.md | 7 + README.md | 46 ++++++ vscode_extension/package.json | 26 +++ vscode_extension/src/client.ts | 24 +++ vscode_extension/src/constants.ts | 28 ++++ vscode_extension/src/delphilsp/autoSync.ts | 174 +++++++++++++++++++++ vscode_extension/src/delphilsp/commands.ts | 25 +++ vscode_extension/src/delphilsp/feature.ts | 49 ++++++ vscode_extension/src/runtime.ts | 6 + 9 files changed, 385 insertions(+) create mode 100644 vscode_extension/src/delphilsp/autoSync.ts create mode 100644 vscode_extension/src/delphilsp/commands.ts create mode 100644 vscode_extension/src/delphilsp/feature.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 510d0c5..b725874 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to the "delphi-devkit" extension will be documented in this Check [Keep a Changelog](http://keepachangelog.com/) for recommendations on how to structure this file. +## [Unreleased] + +### Added + +- **DelphiLSP settings generation** (`ddk delphilsp-config`, MCP `delphi_generate_delphilsp_config`, LSP `delphilsp/generate`): write the `.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. + ## [2.4.0] - 2026-07-14 ### Added diff --git a/README.md b/README.md index 846fdae..8c3a4a2 100644 --- a/README.md +++ b/README.md @@ -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. @@ -65,6 +66,10 @@ ddk run # Run the active project's executable ddk run # Run a project by ID or name (= -p; lists candidates if ambiguous) ddk run # 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 # ...for a specific project (lists candidates if ambiguous) +ddk delphilsp-config -c "Delphi 12" # ...choosing the compiler for an unmanaged path +ddk delphilsp-config -o # ...writing somewhere else instead of next to the project ddk env # Show active project & compiler info ddk info # Print the DDK README ddk --json # Output as JSON @@ -92,6 +97,47 @@ 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 `.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\`. 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). + 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 diff --git a/vscode_extension/package.json b/vscode_extension/package.json index 4aa217a..64239de 100644 --- a/vscode_extension/package.json +++ b/vscode_extension/package.json @@ -299,6 +299,13 @@ "command": "ddk.configuration.openItem", "title": "Open Configuration Item", "category": "DDK" + }, + { + "command": "ddk.projects.generateDelphiLspConfig", + "title": "Generate DelphiLSP Config", + "icon": "$(json)", + "when": "ddk:delphiLspAvailable == true", + "category": "DDK" } ], "views": { @@ -440,6 +447,10 @@ { "command": "ddk.configuration.openItem", "when": "false" + }, + { + "command": "ddk.projects.generateDelphiLspConfig", + "when": "false" } ], "view/title": [ @@ -619,6 +630,11 @@ "command": "ddk.projects.setManualPath", "when": "viewItem == ddk.context.projects.projectFile", "group": "7_paths@2" + }, + { + "command": "ddk.projects.generateDelphiLspConfig", + "when": "view =~ /^ddk\\.view\\.projects.*/ && (viewItem == ddk.context.projects.project || viewItem == ddk.context.projects.projectFile) && ddk:delphiLspAvailable == true", + "group": "8_delphilsp@1" } ] }, @@ -747,6 +763,16 @@ "default": 5000, "minimum": 0, "markdownDescription": "How long (in milliseconds) the compilation result is shown in the status bar after a build finishes. Set to `0` to disable the result display entirely." + }, + "ddk.delphilsp.autoSync": { + "type": "boolean", + "default": true, + "markdownDescription": "Requires the [DelphiLSP](https://marketplace.visualstudio.com/items?itemName=EmbarcaderoTechnologies.delphilsp) extension. Keeps DelphiLSP's active settings file (`delphiLsp.settingsFile`) in sync with DDK's active project: whenever the active project changes, DDK points DelphiLSP at that project's `.delphilsp.json`, auto-generating it first when it is missing, or when it is stale (older than the `.dproj`) **and** was itself generated by DDK. A `.delphilsp.json` produced by the RAD Studio IDE is never touched or regenerated." + }, + "ddk.delphilsp.revalidateOpenFilesOnSwitch": { + "type": "boolean", + "default": true, + "markdownDescription": "After the active-project sync switches `delphiLsp.settingsFile`, make DelphiLSP re-validate every open Delphi file under the new project context (via an invisible language-mode round-trip that re-emits `didOpen` — tabs, focus, cursor, dirty state and undo history are untouched). Without this, DelphiLSP applies the new settings but keeps the stale diagnostics of already-open files until they are edited — its own *Select project settings* command has the same limitation. Only takes effect when `#ddk.delphilsp.autoSync#` is enabled." } } }, diff --git a/vscode_extension/src/client.ts b/vscode_extension/src/client.ts index 37aa27a..347b215 100644 --- a/vscode_extension/src/client.ts +++ b/vscode_extension/src/client.ts @@ -88,6 +88,21 @@ export interface DprojMetadata { active_platform: string; } +/** Mirrors `ddk_core::delphilsp::DelphiLspConfigResult` — the outcome of `delphilsp/generate`. */ +export interface DelphiLspConfigResult { + file_path: string; + project_file: string; + project_uri: string; + dllname: string; + configuration: string; + platform: string; + compiler: string; + search_path_count: number; + browsing_path_count: number; + define_count: number; + warnings: string[]; +} + export class DDK_Client { private client: LanguageClient; private compilerLinkProvider = new CompilerOutputDefinitionProvider(); @@ -128,6 +143,7 @@ export class DDK_Client { await Runtime.projects.workspacesTreeView.refresh(); await Runtime.projects.groupProjectTreeView.refresh(); await Runtime.projects.compilerStatusBarItem.updateDisplay(); + await Runtime.delphilsp?.onProjectsUpdated(); } ); this.client.onNotification( @@ -178,6 +194,7 @@ export class DDK_Client { Runtime.projectsData = data.projects; Runtime.compilerConfigurations = data.compilers; Runtime.updateProjectContexts(); + await Runtime.delphilsp?.onProjectsUpdated(); } catch (e) { window.showErrorMessage(`Failed to fetch configuration from DDK Server: ${e}`); } @@ -262,6 +279,13 @@ export class DDK_Client { return await this.client.sendRequest('dproj/metadata', { project_id: projectId }); } + /** Thin wrapper over the `delphilsp/generate` custom method. `project` is a project id + * (as a string), name, or path — omit to target the currently active project. Throws + * (with a formatted candidate list as the message) when the reference is ambiguous. */ + public async generateDelphiLspConfig(project?: string, compiler?: string, out?: string): Promise { + return await this.client.sendRequest('delphilsp/generate', { project, compiler, out }); + } + public onCompilerProgress(params: CompilerProgressParams) { for (const listener of this.compilerProgressListeners) listener(params); switch (params.kind) { diff --git a/vscode_extension/src/constants.ts b/vscode_extension/src/constants.ts index 0d923a6..cd85742 100644 --- a/vscode_extension/src/constants.ts +++ b/vscode_extension/src/constants.ts @@ -62,6 +62,7 @@ export namespace PROJECTS { export const SET_GROUP_PROJECT_CONFIGURATION = `${PROJECTS.CONFIG.KEY}.setGroupProjectConfiguration`; export const SET_GROUP_PROJECT_PLATFORM = `${PROJECTS.CONFIG.KEY}.setGroupProjectPlatform`; export const TRANSFER_GROUP_PROJECT = `${PROJECTS.CONFIG.KEY}.transferGroupProject`; + export const GENERATE_DELPHILSP_CONFIG = `${PROJECTS.CONFIG.KEY}.generateDelphiLspConfig`; } export namespace CONTEXT { @@ -130,6 +131,33 @@ export namespace DFM { } } +export namespace DELPHILSP { + /** Embarcadero's DelphiLSP VS Code extension. DDK only activates this feature when it is installed. */ + export const EXTENSION_ID = 'embarcaderotechnologies.delphilsp'; + /** Written as the top-level `generatedBy` key of a `.delphilsp.json` DDK produced — mirrors `GENERATED_BY_MARKER` in `core/src/delphilsp/mod.rs`. */ + export const GENERATED_BY_MARKER = 'delphi-devkit'; + + export namespace CONFIG { + export const KEY = 'ddk.delphilsp'; + export const AUTO_SYNC = 'autoSync'; + export const REVALIDATE_ON_SWITCH = 'revalidateOpenFilesOnSwitch'; + } + + export namespace COMMAND { + export const GENERATE_CONFIG = PROJECTS.COMMAND.GENERATE_DELPHILSP_CONFIG; + } + + export namespace CONTEXT { + export const AVAILABLE = 'ddk:delphiLspAvailable'; + } + + /** The DelphiLSP extension's own configuration — not DDK's. */ + export namespace EXTERNAL_SETTINGS { + export const SECTION = 'delphiLsp'; + export const SETTINGS_FILE = 'settingsFile'; + } +} + export namespace COMMANDS { export const EXPORT_PROJECTS = 'ddk.exportProjects'; export const IMPORT_PROJECTS = 'ddk.importProjects'; diff --git a/vscode_extension/src/delphilsp/autoSync.ts b/vscode_extension/src/delphilsp/autoSync.ts new file mode 100644 index 0000000..be6fb7f --- /dev/null +++ b/vscode_extension/src/delphilsp/autoSync.ts @@ -0,0 +1,174 @@ +import { existsSync, promises as fs } from 'fs'; +import { basename, dirname, join } from 'path'; +import { ConfigurationTarget, languages, Uri, window, workspace } from 'vscode'; +import { Runtime } from '../runtime'; +import { Entities } from '../projects/entities'; +import { DELPHILSP } from '../constants'; +import { Option } from '../types'; +import { basenameNoExt } from '../utils'; + +/** + * Keeps DelphiLSP's active `settingsFile` pointed at DDK's active project. + * + * Runs whenever `DelphiLspFeature.onProjectsUpdated` is invoked (project + * state reload / update notification); it only actually does anything the + * first time it sees a given `active_project_id`, so unrelated project + * updates (compile results, discovery, …) don't cause repeated churn. + */ +export namespace DelphiLspAutoSync { + // `undefined` means "never observed yet" — distinct from `null`/no active project, + // so the very first project selection after activation still triggers a sync. + let lastSyncedProjectId: Option = undefined; + + function isAutoSyncEnabled(): boolean { + return workspace.getConfiguration(DELPHILSP.CONFIG.KEY).get(DELPHILSP.CONFIG.AUTO_SYNC, true); + } + + /** `\.delphilsp.json` next to the project's `.dpr`/`.dpk` main + * source — replicates `delphilsp::default_out_path` in `core`. Deliberately + * keyed off the main source's own directory rather than `project.directory`, + * since that is what the generator itself writes next to. */ + function expectedSettingsFilePath(project: Entities.Project): Option { + const mainSource = project.dpr || project.dpk; + if (!mainSource) return undefined; + return join(dirname(mainSource), `${basenameNoExt(mainSource)}.delphilsp.json`); + } + + async function readGeneratedByMarker(filePath: string): Promise> { + try { + const content = await fs.readFile(filePath, 'utf8'); + const parsed = JSON.parse(content); + return typeof parsed?.generatedBy === 'string' ? parsed.generatedBy : undefined; + } catch { + // Unreadable or not valid JSON — treat as "not ours", same as an IDE-generated file. + return undefined; + } + } + + /** A DDK-owned settings file is stale once it is older than the `.dproj` it + * was derived from. Projects with no `.dproj` (bare `.dpr`/`.dpk`) have + * nothing to compare against, so an existing file is always kept. */ + async function isStale(filePath: string, project: Entities.Project): Promise { + if (!project.dproj) return false; + try { + const [settingsStat, dprojStat] = await Promise.all([fs.stat(filePath), fs.stat(project.dproj)]); + return settingsStat.mtimeMs < dprojStat.mtimeMs; + } catch { + return false; + } + } + + /** Ensures the expected `.delphilsp.json` exists and is current, generating + * it through the server when needed. Returns the file path to point + * DelphiLSP at, or `undefined` when nothing could be determined/generated. */ + async function ensureSettingsFile(project: Entities.Project): Promise> { + const filePath = expectedSettingsFilePath(project); + if (!filePath) return undefined; + + let needsGeneration = !existsSync(filePath); + if (!needsGeneration) { + const marker = await readGeneratedByMarker(filePath); + if (marker === DELPHILSP.GENERATED_BY_MARKER) + needsGeneration = await isStale(filePath, project); + // Existing file without our marker was produced by the RAD Studio IDE — leave it untouched. + } + if (!needsGeneration) return filePath; + + try { + const result = await Runtime.client.generateDelphiLspConfig(String(project.id)); + if (result.warnings.length > 0) + console.warn(`[DDK] DelphiLSP config for "${project.name}" generated with warnings:\n${result.warnings.join('\n')}`); + return result.file_path; + } catch (error) { + console.error(`[DDK] Failed to auto-generate DelphiLSP config for "${project.name}": ${error}`); + window.showWarningMessage(`DDK: Failed to generate DelphiLSP settings for "${project.name}": ${error}`); + return undefined; + } + } + + async function pointDelphiLspAt(filePath: string): Promise { + const uri = Uri.file(filePath).toString(); + const config = workspace.getConfiguration(DELPHILSP.EXTERNAL_SETTINGS.SECTION); + if (config.get(DELPHILSP.EXTERNAL_SETTINGS.SETTINGS_FILE) === uri) return; + + const target = (workspace.workspaceFolders?.length ?? 0) > 0 ? ConfigurationTarget.Workspace : ConfigurationTarget.Global; + try { + await config.update(DELPHILSP.EXTERNAL_SETTINGS.SETTINGS_FILE, uri, target); + // Mirror DelphiLSP's own "Loaded project …" toast so the silent auto-switch is visible. + window.showInformationMessage(`DDK: DelphiLSP settings switched to ${basename(filePath)}`); + await revalidateOpenDocuments(); + } catch (error) { + console.error(`[DDK] Failed to update DelphiLSP's settingsFile: ${error}`); + } + } + + /** How long the DelphiLSP server is given to load the pushed settings + * (its client expands and forwards them right after the update) before + * the open editors are re-opened against the new project context. */ + const CONFIG_LOAD_GRACE_MS = 1500; + + function isRevalidateEnabled(): boolean { + return workspace.getConfiguration(DELPHILSP.CONFIG.KEY).get(DELPHILSP.CONFIG.REVALIDATE_ON_SWITCH, true); + } + + function isDelphiSource(fsPath: string): boolean { + return /\.(pas|dpr|dpk)$/i.test(fsPath); + } + + /** + * DelphiLSP's server applies a `settingsFile` change to FUTURE validations + * but never spontaneously re-validates documents that are already open — + * not on the configuration push, and (verified via its raw LSP logs) not + * even after a full server restart with `didOpen` replay; its own "Select + * project settings" command has the same limitation, leaving stale + * diagnostics around until each file is edited. The only trigger it honors + * is a `didOpen` arriving AFTER the new settings are loaded, so: wait for + * the pushed settings to land, then briefly flip each open Delphi + * document's language (objectpascal → plaintext → back). The language flip + * makes VS Code re-emit `didClose`/`didOpen` for the document — which the + * LSP client relays, triggering a re-validation under the new project — + * while the editor tab, focus, cursor, dirty state and undo history stay + * completely untouched (it is the same text buffer; only its language + * label round-trips, with a barely visible syntax-highlight blink). + */ + async function revalidateOpenDocuments(): Promise { + if (!isRevalidateEnabled()) return; + + const delphiDocuments = workspace.textDocuments.filter((doc) => doc.uri.scheme === 'file' && isDelphiSource(doc.fileName)); + if (delphiDocuments.length === 0) return; + + await new Promise((resolve) => setTimeout(resolve, CONFIG_LOAD_GRACE_MS)); + for (const document of delphiDocuments) + try { + // Temporary language round-trip, NOT a cosmetic accident: changing a + // document's language is the only VS Code API that re-emits + // didClose/didOpen for an open document without touching the editor + // (tab, focus, cursor, dirty flag, undo stack all survive — it is the + // same text buffer). The didOpen this produces is what finally makes + // DelphiLSP re-validate the file under the just-switched project; + // nothing else works: the server ignores configuration pushes for + // already-open files and even a full server restart re-plays didOpen + // BEFORE the settings arrive (verified via its raw LSP logs). + const originalLanguage = document.languageId; + const reopened = await languages.setTextDocumentLanguage(document, 'plaintext'); + await languages.setTextDocumentLanguage(reopened, originalLanguage); + } catch (error) { + console.error(`[DDK] Failed to re-validate ${document.fileName} for DelphiLSP: ${error}`); + } + } + + export async function onProjectsUpdated(): Promise { + if (!Runtime.delphilsp?.isAvailable) return; + + const activeId = Runtime.projectsData?.active_project_id ?? undefined; + if (activeId === lastSyncedProjectId) return; + lastSyncedProjectId = activeId; + if (!activeId || !isAutoSyncEnabled()) return; + + const project = Runtime.projectsData?.projects.find((p) => p.id === activeId); + if (!project) return; + + const filePath = await ensureSettingsFile(project); + if (filePath) await pointDelphiLspAt(filePath); + } +} diff --git a/vscode_extension/src/delphilsp/commands.ts b/vscode_extension/src/delphilsp/commands.ts new file mode 100644 index 0000000..021c430 --- /dev/null +++ b/vscode_extension/src/delphilsp/commands.ts @@ -0,0 +1,25 @@ +import { commands, Disposable, window } from 'vscode'; +import { Runtime } from '../runtime'; +import { DELPHILSP } from '../constants'; +import { BaseFileItem } from '../projects/trees/items/baseFile'; +import { assertError } from '../utils'; + +export class DelphiLspCommands { + public static get registers(): Disposable[] { + return [commands.registerCommand(DELPHILSP.COMMAND.GENERATE_CONFIG, this.generateConfig.bind(this))]; + } + + private static async generateConfig(item: BaseFileItem): Promise { + const project = item?.project?.entity; + if (!assertError(project, 'Could not determine project for the selected item.')) return; + + try { + const result = await Runtime.client.generateDelphiLspConfig(String(project.id)); + if (result.warnings.length > 0) + window.showWarningMessage(`DelphiLSP config for "${project.name}" generated with warnings:\n${result.warnings.join('\n')}`); + window.showInformationMessage(`Wrote DelphiLSP settings for "${project.name}": ${result.file_path}`); + } catch (error) { + window.showErrorMessage(`Failed to generate DelphiLSP settings for "${project.name}": ${error}`); + } + } +} diff --git a/vscode_extension/src/delphilsp/feature.ts b/vscode_extension/src/delphilsp/feature.ts new file mode 100644 index 0000000..ca8f694 --- /dev/null +++ b/vscode_extension/src/delphilsp/feature.ts @@ -0,0 +1,49 @@ +import { extensions } from 'vscode'; +import { Feature } from '../types'; +import { Runtime } from '../runtime'; +import { DELPHILSP } from '../constants'; +import { DelphiLspCommands } from './commands'; +import { DelphiLspAutoSync } from './autoSync'; + +/** + * Integrates DDK with Embarcadero's DelphiLSP VS Code extension: generates + * `.delphilsp.json` settings files from DDK's own project state and keeps + * DelphiLSP's active `settingsFile` pointed at whichever project DDK has + * selected. + * + * Everything here is inert unless DelphiLSP (`embarcaderotechnologies.delphilsp`) + * is installed — this is the only feature that is entirely optional based on + * another extension's presence, so it is gated behind its own availability + * flag rather than an `extensionDependencies` entry (DDK is fully useful + * without DelphiLSP). + */ +export class DelphiLspFeature implements Feature { + private _available = false; + + public get isAvailable(): boolean { + return this._available; + } + + public async initialize(): Promise { + this.updateAvailability(); + Runtime.extension.subscriptions.push( + ...DelphiLspCommands.registers, + // DelphiLSP may be installed (or uninstalled) after DDK has already activated. + extensions.onDidChange(() => this.updateAvailability()) + ); + } + + /** Called whenever DDK's project state is (re)loaded, so the active project + * can be compared against the last one auto-synced. Cheap no-op when the + * feature is unavailable or the active project has not actually changed. */ + public async onProjectsUpdated(): Promise { + await DelphiLspAutoSync.onProjectsUpdated(); + } + + private updateAvailability(): void { + const available = !!extensions.getExtension(DELPHILSP.EXTENSION_ID); + if (available === this._available) return; + this._available = available; + Runtime.setContext(DELPHILSP.CONTEXT.AVAILABLE, available); + } +} diff --git a/vscode_extension/src/runtime.ts b/vscode_extension/src/runtime.ts index fc764ff..8485bf4 100644 --- a/vscode_extension/src/runtime.ts +++ b/vscode_extension/src/runtime.ts @@ -8,6 +8,7 @@ import { PROJECTS } from './constants'; import { randomUUID, UUID } from 'crypto'; import { Option } from './types'; import { McpServerFeature } from './mcp/server'; +import { DelphiLspFeature } from './delphilsp/feature'; /** * Runtime class to manage workspace state and global variables. @@ -26,10 +27,15 @@ export abstract class Runtime { public static client: DDK_Client; public static compilerOutputChannel: OutputChannel; public static mcp: McpServerFeature; + public static delphilsp: DelphiLspFeature; static async initialize(context: ExtensionContext) { this.extension = context; this.compilerOutputChannel = window.createOutputChannel('DDK Compiler', 'ddk.compiler'); + // Initialized before the client so its availability flag and hook are + // ready by the time the client's own initial `refresh()` runs. + this.delphilsp = new DelphiLspFeature(); + await this.delphilsp.initialize(); this.client = new DDK_Client(); await this.client.initialize(); this.projects = new ProjectsFeature(); From 73a15c1005388690a85d88972d5f4f25d18cdc98 Mon Sep 17 00:00:00 2001 From: csm101 Date: Sat, 25 Jul 2026 19:06:00 +0200 Subject: [PATCH 3/6] fix(delphilsp): address review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - package.json: 'when' is not valid on contributes.commands — use 'enablement' instead, and let the command appear in the Command Palette (gated on DelphiLSP being installed) now that it falls back to the active project when invoked without a tree item - server: report generation failures as InternalError instead of invalid_params, which misclassified server-side faults - registry: decode env-var overrides with String::from_reg_value instead of RegValue's Display formatting - commands: extract a shared allocation-free is_delphi_project_path helper instead of duplicating the lowercase ends_with chain - file URIs: special-case UNC paths (file://server/share/... keeps the server as the URI authority) + regression test - dccOptions: escape embedded quotes in --description + regression test - autoSync: async existence check instead of existsSync on the extension host thread - tests: cfg(windows) the drive-letter URI assertions --- core/src/commands.rs | 25 ++++++++++++------- core/src/delphilsp/mod.rs | 29 +++++++++++++++++++++- core/src/delphilsp/registry.rs | 4 ++- core/tests/delphilsp.rs | 3 +++ server/src/main.rs | 9 ++++--- vscode_extension/package.json | 4 +-- vscode_extension/src/delphilsp/autoSync.ts | 15 +++++++++-- vscode_extension/src/delphilsp/commands.ts | 15 +++++------ 8 files changed, 79 insertions(+), 25 deletions(-) diff --git a/core/src/commands.rs b/core/src/commands.rs index 3b4e923..64b91b4 100644 --- a/core/src/commands.rs +++ b/core/src/commands.rs @@ -1474,16 +1474,28 @@ pub async fn cmd_run_exe(exe_path: String, args: Option) -> Result) -> Result { - 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 // --------------------------------------------------------------------------- @@ -1621,11 +1633,6 @@ pub async fn cmd_delphilsp_config( compiler: Option, out: Option, ) -> Result { - let is_project_file = |value: &str| { - let lower = value.to_lowercase(); - lower.ends_with(".dproj") || lower.ends_with(".dpr") || lower.ends_with(".dpk") - }; - let request = match target { None => { let active_id = { @@ -1637,7 +1644,7 @@ pub async fn cmd_delphilsp_config( _ => bail!("No active project selected."), } } - Some(reference) if is_project_file(&reference) => { + Some(reference) if is_delphi_project_path(&reference) => { let managed_id = { let data = PROJECTS_DATA.read().await; match resolve_project_by_path(&data, &reference) { diff --git a/core/src/delphilsp/mod.rs b/core/src/delphilsp/mod.rs index b224192..1fde1da 100644 --- a/core/src/delphilsp/mod.rs +++ b/core/src/delphilsp/mod.rs @@ -238,6 +238,11 @@ pub fn path_to_file_uri(path: &Path) -> String { encoded.push_str(&format!("%{byte:02X}")); } } + if normalized.starts_with("//") { + // UNC path: \\server\share\file → file://server/share/file (the + // server is the URI authority, so exactly two slashes after `file:`). + return format!("file:{encoded}"); + } format!("file:///{}", encoded.trim_start_matches('/')) } @@ -365,7 +370,8 @@ pub fn build_dcc_options(input: &DccOptionsInput) -> String { parts.push(format!("-U{include_and_unit}")); } if let Some(description) = input.description.as_ref().filter(|d| !d.trim().is_empty()) { - parts.push(format!("--description:\"{description}\"")); + // Escape embedded quotes so the quoted payload cannot break the option syntax. + parts.push(format!("--description:\"{}\"", description.replace('"', "\\\""))); } if !input.required_packages.is_empty() { parts.push(format!("-LU{};", input.required_packages.join(";"))); @@ -946,6 +952,14 @@ mod tests { ); } + #[test] + fn unc_paths_keep_the_server_as_uri_authority() { + assert_eq!( + path_to_file_uri(Path::new(r"\\server\share\src\App.dpr")), + "file://server/share/src/App.dpr" + ); + } + #[test] fn directory_uris_end_with_a_slash() { assert_eq!(dir_to_file_uri(Path::new(r"C:\a\ObjRepos")), "file:///C%3A/a/ObjRepos/"); @@ -1026,6 +1040,19 @@ mod tests { assert!(options.ends_with(r#"--description:"Hydra About Menu" -LUlibStdFormsD29;"#), "{options}"); } + #[test] + fn escapes_quotes_inside_the_description() { + let input = DccOptionsInput { + description: Some(r#"Hydra "About" Menu"#.to_string()), + ..package_input() + }; + assert!( + build_dcc_options(&input).contains(r#"--description:"Hydra \"About\" Menu""#), + "{}", + build_dcc_options(&input) + ); + } + #[test] fn program_target_uses_exe_extension() { let input = DccOptionsInput { is_package: false, ..package_input() }; diff --git a/core/src/delphilsp/registry.rs b/core/src/delphilsp/registry.rs index 4ad89d5..c4e09ee 100644 --- a/core/src/delphilsp/registry.rs +++ b/core/src/delphilsp/registry.rs @@ -28,6 +28,7 @@ mod imp { use super::IdeLibrarySettings; use winreg::RegKey; use winreg::enums::{HKEY_CURRENT_USER, KEY_READ, REG_EXPAND_SZ, REG_SZ}; + use winreg::types::FromRegValue; fn bds_key(bds_version: &str, sub_key: &str) -> Option { let path = format!("SOFTWARE\\Embarcadero\\BDS\\{bds_version}\\{sub_key}"); @@ -65,7 +66,8 @@ mod imp { // Only string values define a usable `$(NAME)` macro. .filter(|(_, value)| matches!(value.vtype, REG_SZ | REG_EXPAND_SZ)) .filter_map(|(name, value)| { - let text = value.to_string().trim().to_string(); + // Proper registry decoding — `RegValue`'s Display is debug formatting. + let text = String::from_reg_value(&value).ok()?.trim().to_string(); (!name.trim().is_empty() && !text.is_empty()).then_some((name, text)) }) .collect() diff --git a/core/tests/delphilsp.rs b/core/tests/delphilsp.rs index 5f6ffb7..2780555 100644 --- a/core/tests/delphilsp.rs +++ b/core/tests/delphilsp.rs @@ -204,6 +204,9 @@ fn regenerating_over_a_ddk_file_keeps_the_marker() { assert_eq!(written_file(&fixture)["generatedBy"], "delphi-devkit"); } +// The drive-letter assertions (`%3A`, no literal `:`) only hold for Windows +// temp paths; on other platforms temp dirs have no drive prefix. +#[cfg(windows)] #[test] fn project_uri_points_at_the_main_source_not_the_dproj() { let fixture = fixture(None, None); diff --git a/server/src/main.rs b/server/src/main.rs index b56e4b8..e223696 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -105,9 +105,12 @@ impl DelphiLsp { } Err(error) => { lsp_error!(self.client, "Failed to generate DelphiLSP settings: {}", error); - Err(jsonrpc::Error::invalid_params(format!( - "Failed to generate DelphiLSP settings: {error}" - ))) + // Internal failure, not a caller mistake — report it as such. + Err(jsonrpc::Error { + code: jsonrpc::ErrorCode::InternalError, + message: format!("Failed to generate DelphiLSP settings: {error}").into(), + data: None, + }) } } } diff --git a/vscode_extension/package.json b/vscode_extension/package.json index 64239de..0ae9e9d 100644 --- a/vscode_extension/package.json +++ b/vscode_extension/package.json @@ -304,7 +304,7 @@ "command": "ddk.projects.generateDelphiLspConfig", "title": "Generate DelphiLSP Config", "icon": "$(json)", - "when": "ddk:delphiLspAvailable == true", + "enablement": "ddk:delphiLspAvailable == true", "category": "DDK" } ], @@ -450,7 +450,7 @@ }, { "command": "ddk.projects.generateDelphiLspConfig", - "when": "false" + "when": "ddk:delphiLspAvailable == true" } ], "view/title": [ diff --git a/vscode_extension/src/delphilsp/autoSync.ts b/vscode_extension/src/delphilsp/autoSync.ts index be6fb7f..121f757 100644 --- a/vscode_extension/src/delphilsp/autoSync.ts +++ b/vscode_extension/src/delphilsp/autoSync.ts @@ -1,4 +1,4 @@ -import { existsSync, promises as fs } from 'fs'; +import { promises as fs } from 'fs'; import { basename, dirname, join } from 'path'; import { ConfigurationTarget, languages, Uri, window, workspace } from 'vscode'; import { Runtime } from '../runtime'; @@ -34,6 +34,17 @@ export namespace DelphiLspAutoSync { return join(dirname(mainSource), `${basenameNoExt(mainSource)}.delphilsp.json`); } + /** Non-blocking existence check — `existsSync` would stall the extension + * host on slow or network filesystems during every project switch. */ + async function fileExists(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } + } + async function readGeneratedByMarker(filePath: string): Promise> { try { const content = await fs.readFile(filePath, 'utf8'); @@ -65,7 +76,7 @@ export namespace DelphiLspAutoSync { const filePath = expectedSettingsFilePath(project); if (!filePath) return undefined; - let needsGeneration = !existsSync(filePath); + let needsGeneration = !(await fileExists(filePath)); if (!needsGeneration) { const marker = await readGeneratedByMarker(filePath); if (marker === DELPHILSP.GENERATED_BY_MARKER) diff --git a/vscode_extension/src/delphilsp/commands.ts b/vscode_extension/src/delphilsp/commands.ts index 021c430..4e5549d 100644 --- a/vscode_extension/src/delphilsp/commands.ts +++ b/vscode_extension/src/delphilsp/commands.ts @@ -2,24 +2,25 @@ import { commands, Disposable, window } from 'vscode'; import { Runtime } from '../runtime'; import { DELPHILSP } from '../constants'; import { BaseFileItem } from '../projects/trees/items/baseFile'; -import { assertError } from '../utils'; export class DelphiLspCommands { public static get registers(): Disposable[] { return [commands.registerCommand(DELPHILSP.COMMAND.GENERATE_CONFIG, this.generateConfig.bind(this))]; } - private static async generateConfig(item: BaseFileItem): Promise { + /** `item` is set when invoked from the tree context menu; without it + * (Command Palette, keybinding) the server targets the active project. */ + private static async generateConfig(item?: BaseFileItem): Promise { const project = item?.project?.entity; - if (!assertError(project, 'Could not determine project for the selected item.')) return; + const label = project?.name ?? 'the active project'; try { - const result = await Runtime.client.generateDelphiLspConfig(String(project.id)); + const result = await Runtime.client.generateDelphiLspConfig(project ? String(project.id) : undefined); if (result.warnings.length > 0) - window.showWarningMessage(`DelphiLSP config for "${project.name}" generated with warnings:\n${result.warnings.join('\n')}`); - window.showInformationMessage(`Wrote DelphiLSP settings for "${project.name}": ${result.file_path}`); + window.showWarningMessage(`DelphiLSP config for "${label}" generated with warnings:\n${result.warnings.join('\n')}`); + window.showInformationMessage(`Wrote DelphiLSP settings for "${label}": ${result.file_path}`); } catch (error) { - window.showErrorMessage(`Failed to generate DelphiLSP settings for "${project.name}": ${error}`); + window.showErrorMessage(`Failed to generate DelphiLSP settings for "${label}": ${error}`); } } } From a91b3610170810c7323afad8b37237f0c4a53f73 Mon Sep 17 00:00:00 2001 From: csm101 Date: Wed, 29 Jul 2026 10:52:59 +0200 Subject: [PATCH 4/6] feat(delphilsp): merge DDK compile diagnostics with DelphiLSP live analysis With both extensions active the Problems view showed every compile error twice: once from DDK (compiler output, 1-character range) and once from DelphiLSP (live validation, precise token range) - and DDK's copy went stale the moment the user fixed the code, since it only refreshes on the next compile. The server's publishDiagnostics are now routed through the language client's handleDiagnostics middleware into a DiagnosticCollection the extension owns, so individual entries can be deleted later. Whenever DelphiLSP publishes live diagnostics for a file, every DDK entry matching one of them (same line + same Exxxx code, with message/severity fallbacks when a structured code is missing) is deleted - not hidden - so only the precise live entry remains, and when DelphiLSP clears it because the error was fixed in the editor (even without recompiling) no stale compile copy resurfaces. Compile diagnostics DelphiLSP has no opinion on (files it has not validated, link errors) keep their normal lifetime. Without DelphiLSP installed the module is only the rendering route and nothing is ever deleted. New ddk.delphilsp.mergeDiagnostics setting, on by default. --- CHANGELOG.md | 1 + README.md | 13 +++ vscode_extension/package.json | 5 ++ vscode_extension/src/client.ts | 7 ++ vscode_extension/src/constants.ts | 1 + vscode_extension/src/delphilsp/feature.ts | 4 + .../src/delphilsp/mergedDiagnostics.ts | 89 +++++++++++++++++++ 7 files changed, 120 insertions(+) create mode 100644 vscode_extension/src/delphilsp/mergedDiagnostics.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b725874..0443ff6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Check [Keep a Changelog](http://keepachangelog.com/) for recommendations on how - **DelphiLSP settings generation** (`ddk delphilsp-config`, MCP `delphi_generate_delphilsp_config`, LSP `delphilsp/generate`): write the `.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.4.0] - 2026-07-14 diff --git a/README.md b/README.md index 8c3a4a2..4aaef77 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,19 @@ 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 diff --git a/vscode_extension/package.json b/vscode_extension/package.json index 0ae9e9d..ef2c85b 100644 --- a/vscode_extension/package.json +++ b/vscode_extension/package.json @@ -769,6 +769,11 @@ "default": true, "markdownDescription": "Requires the [DelphiLSP](https://marketplace.visualstudio.com/items?itemName=EmbarcaderoTechnologies.delphilsp) extension. Keeps DelphiLSP's active settings file (`delphiLsp.settingsFile`) in sync with DDK's active project: whenever the active project changes, DDK points DelphiLSP at that project's `.delphilsp.json`, auto-generating it first when it is missing, or when it is stale (older than the `.dproj`) **and** was itself generated by DDK. A `.delphilsp.json` produced by the RAD Studio IDE is never touched or regenerated." }, + "ddk.delphilsp.mergeDiagnostics": { + "type": "boolean", + "default": true, + "markdownDescription": "When DelphiLSP reports the same error live (same file, line and error code), drop DDK's copy from the last compile so it is only shown once — with DelphiLSP's precise range — and disappears as soon as the fix is typed, without recompiling. DDK diagnostics DelphiLSP knows nothing about (link errors, files it has not validated) are kept until the next compile. Has no effect when the DelphiLSP extension is not installed." + }, "ddk.delphilsp.revalidateOpenFilesOnSwitch": { "type": "boolean", "default": true, diff --git a/vscode_extension/src/client.ts b/vscode_extension/src/client.ts index 347b215..23cf19d 100644 --- a/vscode_extension/src/client.ts +++ b/vscode_extension/src/client.ts @@ -8,6 +8,7 @@ import { UUID } from 'crypto'; import { join } from 'path'; import { existsSync } from 'fs'; import { CompilerOutputDefinitionProvider } from './projects/compiler/language'; +import { MergedDiagnostics } from './delphilsp/mergedDiagnostics'; import { PROJECTS } from './constants'; export type Change = @@ -125,6 +126,12 @@ export class DDK_Client { const clientOptions: LanguageClientOptions = { initializationOptions: { encoding: workspace.getConfiguration(PROJECTS.SETTINGS.SECTION).get(PROJECTS.SETTINGS.COMPILER_ENCODING, 'oem') + }, + middleware: { + // Route the server's compile diagnostics into a collection DDK + // owns, so single entries can be dropped when DelphiLSP reports + // the same error live (see MergedDiagnostics). + handleDiagnostics: (uri, diagnostics) => MergedDiagnostics.publish(uri, diagnostics) } }; // we can't set the documentSelector until we implement the actual LSP diff --git a/vscode_extension/src/constants.ts b/vscode_extension/src/constants.ts index cd85742..8c50257 100644 --- a/vscode_extension/src/constants.ts +++ b/vscode_extension/src/constants.ts @@ -141,6 +141,7 @@ export namespace DELPHILSP { export const KEY = 'ddk.delphilsp'; export const AUTO_SYNC = 'autoSync'; export const REVALIDATE_ON_SWITCH = 'revalidateOpenFilesOnSwitch'; + export const MERGE_DIAGNOSTICS = 'mergeDiagnostics'; } export namespace COMMAND { diff --git a/vscode_extension/src/delphilsp/feature.ts b/vscode_extension/src/delphilsp/feature.ts index ca8f694..4785119 100644 --- a/vscode_extension/src/delphilsp/feature.ts +++ b/vscode_extension/src/delphilsp/feature.ts @@ -4,6 +4,7 @@ import { Runtime } from '../runtime'; import { DELPHILSP } from '../constants'; import { DelphiLspCommands } from './commands'; import { DelphiLspAutoSync } from './autoSync'; +import { MergedDiagnostics } from './mergedDiagnostics'; /** * Integrates DDK with Embarcadero's DelphiLSP VS Code extension: generates @@ -25,6 +26,9 @@ export class DelphiLspFeature implements Feature { } public async initialize(): Promise { + // Always initialized: it is the rendering route for the server's compile + // diagnostics; the dedup against DelphiLSP is gated internally. + MergedDiagnostics.initialize(); this.updateAvailability(); Runtime.extension.subscriptions.push( ...DelphiLspCommands.registers, diff --git a/vscode_extension/src/delphilsp/mergedDiagnostics.ts b/vscode_extension/src/delphilsp/mergedDiagnostics.ts new file mode 100644 index 0000000..3c2652b --- /dev/null +++ b/vscode_extension/src/delphilsp/mergedDiagnostics.ts @@ -0,0 +1,89 @@ +import { Diagnostic, languages, Uri, workspace } from 'vscode'; +import { DELPHILSP } from '../constants'; +import { Runtime } from '../runtime'; + +/** + * Merges DDK's compile diagnostics with DelphiLSP's live Error Insight. + * + * Both publish standard diagnostics, so after a failed compile the same error + * shows up twice: once from DDK (compiler output, 1-character range) and once + * from DelphiLSP (live validation, precise token range) — and DDK's copy goes + * stale the moment the user fixes the code, since it only refreshes on the + * next compile. + * + * The server's diagnostics are therefore routed into a collection DDK owns + * (via the language client's `handleDiagnostics` middleware), and whenever + * DelphiLSP publishes for a file, every DDK entry matching one of its live + * entries by (line, code) is **deleted** — not hidden — so the more precise + * live entry is the only one shown, and when DelphiLSP later clears it + * because the user fixed the error (without recompiling), no stale DDK copy + * resurfaces. DDK entries DelphiLSP knows nothing about (link errors, files + * it has not validated) stay until the next compile. + * + * Without DelphiLSP installed nothing is ever deleted: compile diagnostics + * keep their current lifetime, and this module is only the rendering route. + */ +export namespace MergedDiagnostics { + const collection = languages.createDiagnosticCollection('DDK Compiler'); + /** `source` labels DDK itself has published (compiler display names) — + * anything else in a file's diagnostics belongs to another extension. */ + const ownSources = new Set(); + + export function initialize(): void { + Runtime.extension.subscriptions.push( + collection, + languages.onDidChangeDiagnostics((event) => { + for (const uri of event.uris) + dedupeAgainstLiveDiagnostics(uri); + }) + ); + } + + /** Rendering route for the server's `publishDiagnostics` (wired as the + * language client's `handleDiagnostics` middleware), kept in an own + * collection so single entries can be deleted later. */ + export function publish(uri: Uri, diagnostics: Diagnostic[]): void { + for (const diagnostic of diagnostics) + if (diagnostic.source) + ownSources.add(diagnostic.source); + collection.set(uri, diagnostics); + } + + function isMergeEnabled(): boolean { + if (!Runtime.delphilsp?.isAvailable) return false; + return workspace.getConfiguration(DELPHILSP.CONFIG.KEY).get(DELPHILSP.CONFIG.MERGE_DIAGNOSTICS, true); + } + + function dedupeAgainstLiveDiagnostics(uri: Uri): void { + if (!isMergeEnabled()) return; + const ours = collection.get(uri); + if (!ours || ours.length === 0) return; + + const foreign = languages.getDiagnostics(uri).filter((entry) => !entry.source || !ownSources.has(entry.source)); + if (foreign.length === 0) return; + + const remaining = ours.filter((mine) => !foreign.some((theirs) => reportSameError(mine, theirs))); + if (remaining.length !== ours.length) + collection.set(uri, remaining); + } + + /** Same line + same compiler error code (`E2003`, …). When one side lacks + * a structured code, fall back to finding DDK's code inside the other + * message, then to severity — a live entry of equal severity on the same + * line supersedes the stale compile entry anyway. */ + function reportSameError(mine: Diagnostic, theirs: Diagnostic): boolean { + if (mine.range.start.line !== theirs.range.start.line) return false; + const mineCode = codeText(mine); + const theirsCode = codeText(theirs); + if (mineCode && theirsCode) return mineCode === theirsCode; + if (mineCode) return theirs.message.includes(mineCode) || mine.severity === theirs.severity; + return mine.severity === theirs.severity; + } + + function codeText(diagnostic: Diagnostic): string | undefined { + const code = diagnostic.code; + if (code === undefined || code === null) return undefined; + if (typeof code === 'object') return String(code.value); + return String(code); + } +} From 0c31d8d96e06eeee052595a18999b0403e77bdb0 Mon Sep 17 00:00:00 2001 From: csm101 Date: Thu, 30 Jul 2026 12:49:41 +0200 Subject: [PATCH 5/6] test(delphilsp): move the dproj fixture to a real .dproj file included via include_str! MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same shape as requested in the PR #7 review — the fixture is easier to open in the IDE and to evolve as a real project file than an inline string literal. --- core/tests/delphilsp.rs | 60 +---------------------------------- core/tests/fixtures/App.dproj | 58 +++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 59 deletions(-) create mode 100644 core/tests/fixtures/App.dproj diff --git a/core/tests/delphilsp.rs b/core/tests/delphilsp.rs index 2780555..8ba182d 100644 --- a/core/tests/delphilsp.rs +++ b/core/tests/delphilsp.rs @@ -12,65 +12,7 @@ use ddk_core::delphilsp::{DccOptionsInput, GenerationRequest, build_dcc_options, /// Minimal but realistic `.dproj`: two configurations, a platform-specific /// group, and the `;$(DCC_…)` inheritance tokens Delphi writes. -const DPROJ: &str = r#" - - App.dpr - True - Debug - Win64 - App - Application - - - true - - - true - Base - true - - - true - Base - true - - - true - Base - true - - - .\$(Platform)\$(Config);..\shared;$(DCC_UnitSearchPath) - .\$(Platform)\$(Config) - .\$(Platform)\$(Config) - System;Vcl;$(DCC_Namespace) - APPWIDE;$(DCC_Define) - false - true - true - - - Winapi;$(DCC_Namespace) - - - RELEASE;$(DCC_Define) - true - - - DEBUG;$(DCC_Define) - off - true - - - MainSource - - - Base - Cfg_1Base - Cfg_2Base - - -"#; +const DPROJ: &str = include_str!("fixtures/App.dproj"); /// A directory tree that looks enough like a Delphi install for the generator: /// `bin\rsvars.bat` plus the two Windows compiler DLLs. diff --git a/core/tests/fixtures/App.dproj b/core/tests/fixtures/App.dproj new file mode 100644 index 0000000..9143027 --- /dev/null +++ b/core/tests/fixtures/App.dproj @@ -0,0 +1,58 @@ + + + App.dpr + True + Debug + Win64 + App + Application + + + true + + + true + Base + true + + + true + Base + true + + + true + Base + true + + + .\$(Platform)\$(Config);..\shared;$(DCC_UnitSearchPath) + .\$(Platform)\$(Config) + .\$(Platform)\$(Config) + System;Vcl;$(DCC_Namespace) + APPWIDE;$(DCC_Define) + false + true + true + + + Winapi;$(DCC_Namespace) + + + RELEASE;$(DCC_Define) + true + + + DEBUG;$(DCC_Define) + off + true + + + MainSource + + + Base + Cfg_1Base + Cfg_2Base + + From 69ee90acdb8133965bf01b7996fbf376dbaedbbb Mon Sep 17 00:00:00 2001 From: csm101 Date: Sun, 2 Aug 2026 11:36:12 +0200 Subject: [PATCH 6/6] refactor(core): consolidate duplicated registry reading and compiler resolution Merging main brought in #7's second implementation of two concepts this branch already had; unify them as announced in the PR discussion: - utils::bds_environment_overrides now owns the Environment Variables registry decoding (REG_SZ/REG_EXPAND_SZ filtering, proper value decoding, trimming) while keeping the Borland/CodeGear/Embarcadero vendor roots; delphilsp::registry::read_ide_environment_variables becomes a thin adapter over it. - compiler_for_project moves from commands.rs onto ProjectsData, and ide_environment_for_project is rewritten on top of it. --- core/src/commands.rs | 22 +-------------------- core/src/delphilsp/registry.rs | 33 +++++++++++-------------------- core/src/projects/project_data.rs | 32 +++++++++++++++++++----------- core/src/utils/mod.rs | 24 ++++++++++++++++------ 4 files changed, 50 insertions(+), 61 deletions(-) diff --git a/core/src/commands.rs b/core/src/commands.rs index a4463f2..4534a67 100644 --- a/core/src/commands.rs +++ b/core/src/commands.rs @@ -1646,26 +1646,6 @@ pub enum DelphiLspOrAmbiguity { Ambiguity(AmbiguousProjects), } -/// The compiler configuration a managed project builds with: its workspace's, -/// or the group project's when the project only lives there. -async fn compiler_for_project( - data: &ProjectsData, - project_id: usize, -) -> Option { - for workspace in &data.workspaces { - if workspace.project_links.iter().any(|l| l.project_id == project_id) { - return Some(workspace.compiler().await); - } - } - let group_project = data.group_project.as_ref()?; - group_project - .project_links - .iter() - .any(|l| l.project_id == project_id) - .then_some(())?; - Some(data.group_projects_compiler().await) -} - /// Build the generation request for a project already managed by DDK. async fn delphilsp_request_for_project( project_id: usize, @@ -1678,7 +1658,7 @@ async fn delphilsp_request_for_project( Some(p) => p, _ => bail!("Project with ID {project_id} not found."), }; - let compiler = match compiler_for_project(&data, project_id).await { + 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.", diff --git a/core/src/delphilsp/registry.rs b/core/src/delphilsp/registry.rs index c4e09ee..a54b1c3 100644 --- a/core/src/delphilsp/registry.rs +++ b/core/src/delphilsp/registry.rs @@ -27,8 +27,7 @@ pub struct IdeLibrarySettings { mod imp { use super::IdeLibrarySettings; use winreg::RegKey; - use winreg::enums::{HKEY_CURRENT_USER, KEY_READ, REG_EXPAND_SZ, REG_SZ}; - use winreg::types::FromRegValue; + use winreg::enums::{HKEY_CURRENT_USER, KEY_READ}; fn bds_key(bds_version: &str, sub_key: &str) -> Option { let path = format!("SOFTWARE\\Embarcadero\\BDS\\{bds_version}\\{sub_key}"); @@ -57,21 +56,6 @@ mod imp { } } - pub fn read_ide_environment_variables(bds_version: &str) -> Vec<(String, String)> { - let Some(key) = bds_key(bds_version, "Environment Variables") else { - return Vec::new(); - }; - key.enum_values() - .filter_map(|entry| entry.ok()) - // Only string values define a usable `$(NAME)` macro. - .filter(|(_, value)| matches!(value.vtype, REG_SZ | REG_EXPAND_SZ)) - .filter_map(|(name, value)| { - // Proper registry decoding — `RegValue`'s Display is debug formatting. - let text = String::from_reg_value(&value).ok()?.trim().to_string(); - (!name.trim().is_empty() && !text.is_empty()).then_some((name, text)) - }) - .collect() - } } #[cfg(not(windows))] @@ -81,10 +65,15 @@ mod imp { pub fn read_ide_library_settings(_bds_version: &str, _platform: &str) -> IdeLibrarySettings { IdeLibrarySettings::default() } - - pub fn read_ide_environment_variables(_bds_version: &str) -> Vec<(String, String)> { - Vec::new() - } } -pub use imp::{read_ide_environment_variables, read_ide_library_settings}; +pub use imp::read_ide_library_settings; + +/// The user-defined environment-variable overrides of one BDS version +/// (`"23.0"`). Thin adapter over [`crate::utils::bds_environment_overrides`], +/// which owns the registry reading (including the Borland/CodeGear vendor +/// roots of pre-XE versions). +pub fn read_ide_environment_variables(bds_version: &str) -> Vec<(String, String)> { + let major = bds_version.split('.').next().and_then(|n| n.parse().ok()).unwrap_or(0); + crate::utils::bds_environment_overrides(major) +} diff --git a/core/src/projects/project_data.rs b/core/src/projects/project_data.rs index 5094668..718a27a 100644 --- a/core/src/projects/project_data.rs +++ b/core/src/projects/project_data.rs @@ -74,23 +74,31 @@ impl ProjectsData { } } - /// The IDE environment-variable overrides applying to a project: those of - /// the compiler configuration of the first workspace containing it, else - /// the group project's compiler when it is a group-project member, else - /// (no owning context to pick a configuration from) the fallback set of - /// the highest installed BDS version. - pub async fn ide_environment_for_project(&self, project_id: usize) -> Vec<(String, String)> { + /// The compiler configuration a managed project builds with: that of the + /// first workspace containing it, else the group project's compiler when + /// it is a group-project member, else `None` (orphan project). + pub async fn compiler_for_project(&self, project_id: usize) -> Option { for workspace in &self.workspaces { if workspace.project_links.iter().any(|link| link.project_id == project_id) { - return workspace.compiler().await.ide_environment_overrides(); + return Some(workspace.compiler().await); } } - if let Some(group_project) = &self.group_project { - if group_project.project_links.iter().any(|link| link.project_id == project_id) { - return self.group_projects_compiler().await.ide_environment_overrides(); - } + let group_project = self.group_project.as_ref()?; + if group_project.project_links.iter().any(|link| link.project_id == project_id) { + return Some(self.group_projects_compiler().await); + } + None + } + + /// The IDE environment-variable overrides applying to a project: those of + /// its compiler configuration ([`Self::compiler_for_project`]), else (no + /// owning context to pick a configuration from) the fallback set of the + /// highest installed BDS version. + pub async fn ide_environment_for_project(&self, project_id: usize) -> Vec<(String, String)> { + match self.compiler_for_project(project_id).await { + Some(compiler) => compiler.ide_environment_overrides(), + _ => crate::utils::ide_environment_overrides(), } - crate::utils::ide_environment_overrides() } async fn validate_compilers(&self) -> Result<()> { diff --git a/core/src/utils/mod.rs b/core/src/utils/mod.rs index 163a723..f9f7d01 100644 --- a/core/src/utils/mod.rs +++ b/core/src/utils/mod.rs @@ -31,10 +31,26 @@ pub fn bds_environment_overrides(bds_major_version: usize) -> Vec<(String, Strin let Ok(env_key) = hkcu.open_subkey(key_path) else { return Vec::new(); }; + read_environment_values(&env_key) +} + +/// Decode the name/value pairs of an IDE `Environment Variables` registry key. +/// Only string values (`REG_SZ`/`REG_EXPAND_SZ`) define a usable `$(NAME)` +/// variable; names and values are trimmed and empty ones dropped. +#[cfg(windows)] +fn read_environment_values(env_key: &winreg::RegKey) -> Vec<(String, String)> { + use winreg::enums::{REG_EXPAND_SZ, REG_SZ}; + use winreg::types::FromRegValue; + env_key .enum_values() .flatten() - .map(|(name, value)| (name, value.to_string())) + .filter(|(_, value)| matches!(value.vtype, REG_SZ | REG_EXPAND_SZ)) + .filter_map(|(name, value)| { + // Proper registry decoding — `RegValue`'s Display is debug formatting. + let text = String::from_reg_value(&value).ok()?.trim().to_string(); + (!name.trim().is_empty() && !text.is_empty()).then_some((name, text)) + }) .collect() } @@ -66,11 +82,7 @@ pub fn ide_environment_overrides() -> Vec<(String, String)> { let Ok(env_key) = bds.open_subkey(format!(r"{version}\Environment Variables")) else { continue; }; - let overrides: Vec<(String, String)> = env_key - .enum_values() - .flatten() - .map(|(name, value)| (name, value.to_string())) - .collect(); + let overrides = read_environment_values(&env_key); if !overrides.is_empty() { return overrides; }