diff --git a/CHANGELOG.md b/CHANGELOG.md index e869c40..1d33af5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ Check [Keep a Changelog](http://keepachangelog.com/) for recommendations on how ## [Unreleased] +### Added + +- **DelphiLSP settings generation** (`ddk delphilsp-config`, MCP `delphi_generate_delphilsp_config`, LSP `delphilsp/generate`): write the `.delphilsp.json` settings file Embarcadero's DelphiLSP VS Code extension needs for code insight, without ever opening RAD Studio. The file is reconstructed from the project's `.dproj` (evaluated for the effective configuration/platform), the compiler's `rsvars.bat`, and the IDE's global Library Path / Browsing Path / environment-variable overrides read from the registry. Works on managed projects (ID/name) and ad-hoc `.dproj` paths (`-c` picks the compiler, `-o` overrides the destination). Files DDK writes carry a `"generatedBy": "delphi-devkit"` marker; IDE-generated files are never touched. +- **DelphiLSP auto-sync (VS Code)**: when the DelphiLSP extension is installed, DDK keeps its `delphiLsp.settingsFile` pointed at the DDK active project — generating the settings file on the fly when missing (or stale and DDK-owned) — and re-validates every open Delphi file under the new project context after each switch (something DelphiLSP's own *Select project settings* command does not do). New **Generate DelphiLSP Config** project context-menu action, and `ddk.delphilsp.autoSync` / `ddk.delphilsp.revalidateOpenFilesOnSwitch` settings (both on by default). Nothing appears when DelphiLSP is not installed. +- **Merged diagnostics (VS Code)**: with DelphiLSP installed, the Problems view no longer shows the same error twice (once from DDK's last compile, once from DelphiLSP's live analysis). DDK compile diagnostics matching a live DelphiLSP entry (same line, same error code) are removed in favor of the live entry's precise token range — and once DelphiLSP reports the error as fixed in the editor, the compile copy stays gone even without recompiling. Compile diagnostics DelphiLSP has no opinion on (unvalidated files, link errors) keep their normal lifetime; without DelphiLSP nothing is ever removed. `ddk.delphilsp.mergeDiagnostics` setting (on by default). + ## [2.5.1] - 2026-07-31 ### Added diff --git a/Cargo.lock b/Cargo.lock index 9decdbe..433e8bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -140,6 +140,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bstr" version = "1.12.1" @@ -257,6 +266,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -282,6 +300,16 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "dashmap" version = "5.5.3" @@ -327,6 +355,7 @@ dependencies = [ "scopeguard", "serde", "serde_json", + "sha2", "tempfile", "tokio", "tower-lsp", @@ -366,6 +395,16 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "dirs" version = "4.0.0" @@ -587,6 +626,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -1488,6 +1537,17 @@ dependencies = [ "syn", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "1.3.0" @@ -1830,6 +1890,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -1884,6 +1950,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "walkdir" version = "2.5.0" diff --git a/README.md b/README.md index dcee094..9aaa84e 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. @@ -68,6 +69,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 @@ -121,6 +126,64 @@ 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 its `.dproj` content has changed +since generation — so code insight always resolves units, defines and search +paths in the context of the project you are actually working on. Because the +generated files are machine-specific, DDK also keeps them out of version +control by adding `*.delphilsp.json` to the owning repository's local +`.git/info/exclude` (`ddk.delphilsp.autoIgnoreDelphiLspFiles`, on by default; +disabling it removes exactly the entry DDK added, nothing else). Because DelphiLSP's server applies a +settings change only to files opened afterwards (its own *Select project +settings* command leaves already-open files with stale diagnostics until they +are edited), DDK also re-validates every open Delphi file after the switch via +an invisible language-mode round-trip that re-emits `didOpen` — tabs, focus, +cursor, dirty state and undo history are untouched +(`ddk.delphilsp.revalidateOpenFilesOnSwitch`, on by default). + +With both extensions active the **Problems** view would otherwise show the same +error twice — once from DDK's last compile and once from DelphiLSP's live +analysis — and DDK's copy would go stale the moment the code is fixed, since it +only refreshes on the next compile. DDK therefore merges the two: whenever +DelphiLSP reports live diagnostics for a file, every DDK compile diagnostic +matching one of them (same line, same `Exxxx` code) is removed, leaving only +the live entry with its precise token range — and when DelphiLSP later clears +that entry because the error was fixed in the editor (even without +recompiling), no stale compile copy resurfaces. Compile diagnostics DelphiLSP +knows nothing about (files it has not validated, link errors) keep their normal +lifetime, and without DelphiLSP installed nothing is ever removed +(`ddk.delphilsp.mergeDiagnostics`, on by default). + Without `--args`, a project runs with the `.dproj`'s own `Debugger_RunParams` — the Run Parameters set via Project > Options > Run in the Delphi IDE, resolved against the project's active configuration/platform — fused with diff --git a/cli/src/main.rs b/cli/src/main.rs index 8798e46..55810e1 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -147,6 +147,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, @@ -422,6 +448,26 @@ async fn main() -> Result<()> { } } + Commands::DelphiLspConfig { target, compiler, out } => { + use commands::DelphiLspOrAmbiguity; + match commands::cmd_delphilsp_config(target, compiler, out).await? { + DelphiLspOrAmbiguity::Output(result) => { + if cli.json { + println!("{}", serde_json::to_string_pretty(&result)?); + } else { + println!("{result}"); + } + } + DelphiLspOrAmbiguity::Ambiguity(a) => { + if cli.json { + println!("{}", serde_json::to_string_pretty(&a)?); + } else { + print!("{a}"); + } + } + } + } + Commands::Format { file, encoding } => { let result = commands::cmd_format_file(file, encoding).await?; if cli.json { diff --git a/core/Cargo.toml b/core/Cargo.toml index 3d8ed62..9879d8e 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -33,6 +33,7 @@ tempfile = "3.24.0" tokio = { version = "1.49.0", features = ["fs", "io-std", "io-util", "macros", "process", "rt-multi-thread", "time", "tokio-macros"] } tower-lsp = { version = "0.20.0" } dproj-rs = "0.3.0" +sha2 = "0.10" [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.59", features = ["Win32_NetworkManagement_WNet"] } diff --git a/core/src/commands.rs b/core/src/commands.rs index ed8c520..fdf5fd0 100644 --- a/core/src/commands.rs +++ b/core/src/commands.rs @@ -8,7 +8,9 @@ use anyhow::{Context, Result, bail}; use regex::Regex; use serde::{Deserialize, Serialize}; use std::fmt; +use std::path::PathBuf; +use crate::files::dproj::{find_dproj_file, get_main_source}; use crate::lsp_types::{CompileProjectParams, CompilerProgress, CompilerProgressParams}; use crate::projects::*; use crate::state::*; @@ -1612,16 +1614,195 @@ 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 +// --------------------------------------------------------------------------- + +/// Result of generating a `.delphilsp.json`: either the written file's summary, +/// or the candidate list when the project reference was ambiguous. +#[derive(Debug, Clone)] +pub enum DelphiLspOrAmbiguity { + Output(crate::delphilsp::DelphiLspConfigResult), + Ambiguity(AmbiguousProjects), +} + +/// Build the generation request for a project already managed by DDK. +async fn delphilsp_request_for_project( + project_id: usize, + out: Option, +) -> Result { + let data = PROJECTS_DATA.read().await; + let project = match data.get_project(project_id) { + Some(p) => p, + _ => bail!("Project with ID {project_id} not found."), + }; + let compiler = match data.compiler_for_project(project_id).await { + Some(c) => c, + _ => bail!( + "Project \"{}\" is not linked to a workspace or the group project, so no compiler can be determined.", + project.name + ), + }; + let dproj_path = project.dproj.as_ref().map(PathBuf::from); + let main_source = project + .dpr + .as_ref() + .or(project.dpk.as_ref()) + .map(PathBuf::from) + .or_else(|| dproj_path.as_ref().and_then(|p| crate::files::dproj::get_main_source(p).ok())); + let main_source = match main_source { + Some(path) => path, + _ => bail!( + "Project \"{}\" has no .dpr/.dpk main source to describe.", + project.name + ), + }; + Ok(crate::delphilsp::GenerationRequest { + dproj_path, + main_source, + configuration: project.active_configuration.clone(), + platform: project.active_platform.clone(), + installation_path: PathBuf::from(&compiler.installation_path), + bds_version: format!("{}.0", compiler.product_version), + 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 { + 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), + bds_version: format!("{}.0", config.product_version), + 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 request = match target { + None => { + let active_id = { + let data = PROJECTS_DATA.read().await; + data.active_project_id + }; + match active_id { + Some(id) => delphilsp_request_for_project(id, out).await?, + _ => bail!("No active project selected."), + } + } + Some(reference) if is_delphi_project_path(&reference) => { + let managed_id = { + let data = PROJECTS_DATA.read().await; + match resolve_project_by_path(&data, &reference) { + ProjectResolution::Single(id) => Some(id), + ProjectResolution::Ambiguous(matches) => { + return Ok(DelphiLspOrAmbiguity::Ambiguity(AmbiguousProjects { + reference, + matches, + })); + } + ProjectResolution::NotFound => None, + } + }; + match managed_id { + Some(id) => delphilsp_request_for_project(id, out).await?, + _ => delphilsp_request_for_path(&reference, compiler, out).await?, + } + } + Some(reference) => { + let resolved = { + let data = PROJECTS_DATA.read().await; + resolve_project_reference(&data, &reference) + }; + match resolved { + ProjectResolution::Single(id) => delphilsp_request_for_project(id, out).await?, + ProjectResolution::Ambiguous(matches) => { + return Ok(DelphiLspOrAmbiguity::Ambiguity(AmbiguousProjects { + reference, + matches, + })); + } + ProjectResolution::NotFound => bail!( + "No project matches \"{reference}\". Use `list` to see available projects." + ), + } + } + }; + + Ok(DelphiLspOrAmbiguity::Output(crate::delphilsp::generate(&request)?)) +} + /// Formats a Delphi source file in-place. /// /// Reads the file at `file_path`, decodes it with `encoding` (e.g. `"utf-8"`, diff --git a/core/src/delphilsp/mod.rs b/core/src/delphilsp/mod.rs new file mode 100644 index 0000000..856d189 --- /dev/null +++ b/core/src/delphilsp/mod.rs @@ -0,0 +1,1021 @@ +//! 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::fmt; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::projects::MacroMap; +use crate::utils::{dir_to_file_uri, path_to_file_uri, trim_trailing_separator}; + +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 = concat!( + "Generics.Collections=System.Generics.Collections;", + "Generics.Defaults=System.Generics.Defaults;", + "WinTypes=Winapi.Windows;", + "WinProcs=Winapi.Windows;", + "DbiTypes=BDE;", + "DbiProcs=BDE;", + "DbiErrs=BDE", +); + +// --------------------------------------------------------------------------- +// Path list helpers +// --------------------------------------------------------------------------- + +/// 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(";") +} + +// --------------------------------------------------------------------------- +// 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()) { + // 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(";"))); + } + + 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, + /// SHA-256 (lowercase hex) of the `.dproj` bytes the settings were derived + /// from. The VS Code auto-sync compares it against the current `.dproj` to + /// decide staleness — timestamps are unreliable on Windows, and an + /// untouched-content `.dproj` with a fresh mtime must not trigger a + /// regeneration. Absent on IDE files and for bare `.dpr`/`.dpk` projects. + #[serde(rename = "dprojHash", skip_serializing_if = "Option::is_none")] + dproj_hash: 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, + /// BDS version of the compiler configuration, e.g. `"23.0"` for Delphi 12 + /// (`CompilerConfiguration::product_version` + `.0`) — selects the + /// registry hive and the IDE data directories. + pub bds_version: String, + /// 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 = request.bds_version.clone(); + + // ── 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); + } + if let Some(common_dir) = bds_common_dir(&bds_version) { + macros.set_default("BDSCOMMONDIR", common_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()), + dproj_hash: request.dproj_path.as_deref().and_then(dproj_content_hash), + }; + 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, + }) +} + +/// SHA-256 of the `.dproj` bytes, lowercase hex — the staleness fingerprint +/// stored as `dprojHash`. The VS Code auto-sync computes the same digest +/// (`node:crypto`), so the two must never diverge. +fn dproj_content_hash(dproj_path: &Path) -> Option { + let bytes = std::fs::read(dproj_path).ok()?; + Some(format!("{:x}", Sha256::digest(&bytes))) +} + +/// `\.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() +} + +/// The IDE data folder name under a Documents root: `RAD Studio` for the +/// D2007–D2010 era (BDS 5.0–7.0), `Embarcadero\Studio` from XE (8.0) on. +fn bds_documents_subpath(bds_version: &str) -> PathBuf { + let major: usize = bds_version.split('.').next().and_then(|n| n.parse().ok()).unwrap_or(0); + if major <= 7 { + PathBuf::from("RAD Studio") + } else { + Path::new("Embarcadero").join("Studio") + } +} + +/// `\Embarcadero\Studio\` (or `\RAD Studio\` +/// for pre-XE versions) — the IDE's `$(BDSUSERDIR)`. +fn bds_user_dir(bds_version: &str) -> Option { + let documents = dirs::document_dir()?; + Some( + documents + .join(bds_documents_subpath(bds_version)) + .join(bds_version) + .to_string_lossy() + .to_string(), + ) +} + +/// The Public Documents counterpart of [`bds_user_dir`] — the IDE's +/// `$(BDSCOMMONDIR)`. Only a fallback: `rsvars.bat` normally defines the +/// variable and wins. +fn bds_common_dir(bds_version: &str) -> Option { + let public = std::env::var_os("PUBLIC")?; + Some( + Path::new(&public) + .join("Documents") + .join(bds_documents_subpath(bds_version)) + .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 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/"); + 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 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() }; + 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..a54b1c3 --- /dev/null +++ b/core/src/delphilsp/registry.rs @@ -0,0 +1,79 @@ +//! 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}; + + 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"), + } + } + +} + +#[cfg(not(windows))] +mod imp { + use super::IdeLibrarySettings; + + pub fn read_ide_library_settings(_bds_version: &str, _platform: &str) -> IdeLibrarySettings { + IdeLibrarySettings::default() + } +} + +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/lib.rs b/core/src/lib.rs index 22defd2..cdd784e 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 lsp_types; pub mod files; diff --git a/core/src/lsp_types.rs b/core/src/lsp_types.rs index 18b426b..e5fe62b 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 { /// The full document text. Range formatting still needs the whole file so diff --git a/core/src/projects/compiler/macro_map.rs b/core/src/projects/compiler/macro_map.rs new file mode 100644 index 0000000..eaf045e --- /dev/null +++ b/core/src/projects/compiler/macro_map.rs @@ -0,0 +1,118 @@ +//! Case-insensitive `$(NAME)` variable expansion. +//! +//! Lives with the compiler code because every source it aggregates is tied to +//! a concrete Delphi installation: `rsvars.bat` variables, the IDE's registry +//! environment-variable overrides, and the per-build `Config`/`Platform` +//! context — unlike `dproj-rs`, which evaluates a `.dproj` in isolation. + +use std::collections::HashMap; + +/// 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 + } +} diff --git a/core/src/projects/compiler/mod.rs b/core/src/projects/compiler/mod.rs index 29fa5e6..37b0246 100644 --- a/core/src/projects/compiler/mod.rs +++ b/core/src/projects/compiler/mod.rs @@ -1,4 +1,6 @@ pub mod compiler_state; +pub mod macro_map; +pub use macro_map::MacroMap; use super::*; use crate::files::dproj as dproj_cache; 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..946d7b1 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; } @@ -83,6 +95,48 @@ pub fn ide_environment_overrides() -> Vec<(String, String)> { Vec::new() } +/// Strip trailing path separators (`c:\foo\` → `c:\foo`) without eating a +/// drive root (`c:\` stays `c:\`). +pub fn trim_trailing_separator(path: &str) -> &str { + let trimmed = path.trim_end_matches(['\\', '/']); + if trimmed.is_empty() || trimmed.ends_with(':') { + path + } else { + trimmed + } +} + +/// Percent-encode a Windows path into the `file:///C%3A/dir/file.dpk` form the +/// RAD Studio 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}")); + } + } + 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('/')) +} + +/// 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}/") } +} + /// Normalise a path by: /// 1. Resolving `.` and `..` segments purely (without touching the filesystem). /// 2. Stripping the Windows extended-length prefix (`\\?\`) if present. diff --git a/core/tests/commands.rs b/core/tests/commands.rs index 46536d6..08ce6d1 100644 --- a/core/tests/commands.rs +++ b/core/tests/commands.rs @@ -450,3 +450,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..933bf1a --- /dev/null +++ b/core/tests/delphilsp.rs @@ -0,0 +1,276 @@ +//! 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 = 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. +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, + bds_version: "23.0".to_string(), + 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(), + 3, + "expected exactly `settings`, `generatedBy` and `dprojHash`: {file}" + ); + + // The staleness fingerprint: SHA-256 of the dproj bytes, lowercase hex. + let hash = file["dprojHash"].as_str().expect("dprojHash missing"); + assert_eq!(hash.len(), 64, "{hash}"); + assert!(hash.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()), "{hash}"); +} + +#[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"); +} + +// 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); + 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/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 + + diff --git a/mcp/src/handler.rs b/mcp/src/handler.rs index 5fe7beb..5eb7ba2 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)])) @@ -472,6 +502,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 aa8709c..7406a65 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -104,6 +104,34 @@ 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); + // 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, + }) + } + } + } + async fn dproj_metadata( &self, params: DprojMetadataParams, @@ -242,6 +270,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; diff --git a/vscode_extension/package.json b/vscode_extension/package.json index 253f515..2f9974b 100644 --- a/vscode_extension/package.json +++ b/vscode_extension/package.json @@ -305,6 +305,13 @@ "command": "ddk.configuration.openItem", "title": "Open Configuration Item", "category": "DDK" + }, + { + "command": "ddk.projects.generateDelphiLspConfig", + "title": "Generate DelphiLSP Config", + "icon": "$(json)", + "enablement": "ddk:delphiLspAvailable == true", + "category": "DDK" } ], "views": { @@ -450,6 +457,10 @@ { "command": "ddk.configuration.openItem", "when": "false" + }, + { + "command": "ddk.projects.generateDelphiLspConfig", + "when": "ddk:delphiLspAvailable == true" } ], "view/title": [ @@ -634,6 +645,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" } ] }, @@ -762,6 +778,26 @@ "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 (the `.dproj` content changed since it was generated) **and** was itself generated by DDK. A `.delphilsp.json` produced by the RAD Studio IDE is never touched or regenerated." + }, + "ddk.delphilsp.autoIgnoreDelphiLspFiles": { + "type": "boolean", + "default": true, + "markdownDescription": "Keep generated `.delphilsp.json` files out of version control: whenever DDK writes or syncs one, a `*.delphilsp.json` entry is appended to the owning repository's `.git/info/exclude` (the local, never-committed counterpart of `.gitignore`). Disabling the setting removes the entry again — but only the one DDK itself added; a `*.delphilsp.json` line you wrote yourself is never touched, and nothing is added when the pattern is already excluded." + }, + "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, + "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 78c5fdc..536d551 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'; /** @@ -107,6 +108,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(); @@ -129,6 +145,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 @@ -147,6 +169,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( @@ -197,6 +220,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}`); } @@ -281,6 +305,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 d46a26f..86f3ec1 100644 --- a/vscode_extension/src/constants.ts +++ b/vscode_extension/src/constants.ts @@ -63,6 +63,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 { @@ -131,6 +132,35 @@ 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 AUTO_IGNORE = 'autoIgnoreDelphiLspFiles'; + export const REVALIDATE_ON_SWITCH = 'revalidateOpenFilesOnSwitch'; + export const MERGE_DIAGNOSTICS = 'mergeDiagnostics'; + } + + 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..3dfefbb --- /dev/null +++ b/vscode_extension/src/delphilsp/autoSync.ts @@ -0,0 +1,188 @@ +import { createHash } from 'crypto'; +import { 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, fileExists } from '../utils'; +import { DelphiLspGitExclude } from './gitExclude'; + +/** + * 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; + + /** `\.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`); + } + + interface SettingsFileMarkers { + generatedBy?: string; + dprojHash?: string; + } + + async function readSettingsFileMarkers(filePath: string): Promise { + try { + const content = await fs.readFile(filePath, 'utf8'); + const parsed = JSON.parse(content); + return { + generatedBy: typeof parsed?.generatedBy === 'string' ? parsed.generatedBy : undefined, + dprojHash: typeof parsed?.dprojHash === 'string' ? parsed.dprojHash : undefined, + }; + } catch { + // Unreadable or not valid JSON — treat as "not ours", same as an IDE-generated file. + return {}; + } + } + + /** A DDK-owned settings file is stale once the `.dproj` it was derived from + * has different content: its stored `dprojHash` (SHA-256 of the dproj + * bytes, written by the generator in `core`) no longer matches. Content is + * compared instead of timestamps because mtimes are unreliable on Windows + * and a rewritten-but-identical `.dproj` must not trigger a regeneration. + * A DDK file without the hash predates it — regenerate once to stamp it. + * Projects with no `.dproj` (bare `.dpr`/`.dpk`) have nothing to compare + * against, so an existing file is always kept. */ + async function isStale(markers: SettingsFileMarkers, project: Entities.Project): Promise { + if (!project.dproj) return false; + if (!markers.dprojHash) return true; + try { + const dprojBytes = await fs.readFile(project.dproj); + return createHash('sha256').update(dprojBytes).digest('hex') !== markers.dprojHash; + } 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 = !(await fileExists(filePath)); + if (!needsGeneration) { + const markers = await readSettingsFileMarkers(filePath); + if (markers.generatedBy === DELPHILSP.GENERATED_BY_MARKER) + needsGeneration = await isStale(markers, 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?.canAutoGenerate) return; + + const activeId = Runtime.projectsData?.active_project_id ?? undefined; + if (activeId === lastSyncedProjectId) return; + lastSyncedProjectId = activeId; + if (!activeId) return; + + const project = Runtime.projectsData?.projects.find((p) => p.id === activeId); + if (!project) return; + + const filePath = await ensureSettingsFile(project); + if (!filePath) return; + await DelphiLspGitExclude.ensureExcludedFor(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..f51b403 --- /dev/null +++ b/vscode_extension/src/delphilsp/commands.ts @@ -0,0 +1,28 @@ +import { commands, Disposable, window } from 'vscode'; +import { Runtime } from '../runtime'; +import { DELPHILSP } from '../constants'; +import { BaseFileItem } from '../projects/trees/items/baseFile'; +import { DelphiLspGitExclude } from './gitExclude'; + +export class DelphiLspCommands { + public static get registers(): Disposable[] { + return [commands.registerCommand(DELPHILSP.COMMAND.GENERATE_CONFIG, this.generateConfig.bind(this))]; + } + + /** `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; + const label = project?.name ?? 'the active project'; + + try { + const result = await Runtime.client.generateDelphiLspConfig(project ? String(project.id) : undefined); + await DelphiLspGitExclude.ensureExcludedFor(result.file_path); + if (result.warnings.length > 0) + 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 "${label}": ${error}`); + } + } +} diff --git a/vscode_extension/src/delphilsp/feature.ts b/vscode_extension/src/delphilsp/feature.ts new file mode 100644 index 0000000..a3fe103 --- /dev/null +++ b/vscode_extension/src/delphilsp/feature.ts @@ -0,0 +1,69 @@ +import { extensions, workspace } from 'vscode'; +import { Feature } from '../types'; +import { Runtime } from '../runtime'; +import { DELPHILSP } from '../constants'; +import { DelphiLspCommands } from './commands'; +import { DelphiLspAutoSync } from './autoSync'; +import { DelphiLspGitExclude } from './gitExclude'; +import { MergedDiagnostics } from './mergedDiagnostics'; + +/** + * 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 extensionAvailable = false; + + /** The DelphiLSP extension is installed. Mirrored into the + * `ddk:delphiLspAvailable` context key, which gates the commands' + * `when`/`enablement` clauses. */ + public get isDelphiLspExtensionAvailable(): boolean { + return this.extensionAvailable; + } + + /** Whether the auto-sync may generate settings files and repoint + * DelphiLSP: the extension must be installed AND the user opted in via + * the `autoSync` setting. */ + public get canAutoGenerate(): boolean { + if (!this.extensionAvailable) return false; + return workspace.getConfiguration(DELPHILSP.CONFIG.KEY).get(DELPHILSP.CONFIG.AUTO_SYNC, true); + } + + 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, + // DelphiLSP may be installed (or uninstalled) after DDK has already activated. + extensions.onDidChange(() => this.updateAvailability()), + workspace.onDidChangeConfiguration((event) => { + if (event.affectsConfiguration(`${DELPHILSP.CONFIG.KEY}.${DELPHILSP.CONFIG.AUTO_IGNORE}`)) + void DelphiLspGitExclude.onSettingChanged(); + }) + ); + } + + /** 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.extensionAvailable) return; + this.extensionAvailable = available; + Runtime.setContext(DELPHILSP.CONTEXT.AVAILABLE, available); + } +} diff --git a/vscode_extension/src/delphilsp/gitExclude.ts b/vscode_extension/src/delphilsp/gitExclude.ts new file mode 100644 index 0000000..e365d82 --- /dev/null +++ b/vscode_extension/src/delphilsp/gitExclude.ts @@ -0,0 +1,129 @@ +import { promises as fs } from 'fs'; +import { dirname, join, resolve } from 'path'; +import { workspace } from 'vscode'; +import { Runtime } from '../runtime'; +import { DELPHILSP } from '../constants'; +import { Option } from '../types'; + +/** + * Keeps generated `.delphilsp.json` files out of version control by managing a + * `*.delphilsp.json` entry in the owning repository's `.git/info/exclude` + * (the local, never-committed counterpart of `.gitignore`). + * + * Only the entry DDK itself wrote (recognised by its marker comment) is ever + * touched: a user's own `*.delphilsp.json` line is left alone when the setting + * is turned off, and nothing is added when the pattern is already excluded. + */ +export namespace DelphiLspGitExclude { + const MARKER = '# Managed by Delphi DevKit (ddk.delphilsp.autoIgnoreDelphiLspFiles)'; + const PATTERN = '*.delphilsp.json'; + + function isAutoIgnoreEnabled(): boolean { + return workspace.getConfiguration(DELPHILSP.CONFIG.KEY).get(DELPHILSP.CONFIG.AUTO_IGNORE, true); + } + + /** Walk up from `startDir` to the repository root and resolve its + * `info/exclude` path — following a `.git` *file* (linked worktree or + * submodule) to the real git dir, and a worktree's `commondir` to the + * shared one, where `info/exclude` actually lives. */ + async function findGitInfoExcludePath(startDir: string): Promise> { + let dir = startDir; + for (;;) { + const dotGit = join(dir, '.git'); + try { + const stat = await fs.stat(dotGit); + if (stat.isDirectory()) return join(dotGit, 'info', 'exclude'); + const gitDirReference = /^gitdir:\s*(.+)$/m.exec(await fs.readFile(dotGit, 'utf8')); + if (!gitDirReference) return undefined; + let gitDir = resolve(dir, gitDirReference[1].trim()); + try { + const commonDir = (await fs.readFile(join(gitDir, 'commondir'), 'utf8')).trim(); + gitDir = resolve(gitDir, commonDir); + } catch { + // No `commondir` — a plain git dir already. + } + return join(gitDir, 'info', 'exclude'); + } catch { + // No `.git` at this level — keep walking up. + } + const parent = dirname(dir); + if (parent === dir) return undefined; + dir = parent; + } + } + + function hasPattern(lines: string[]): boolean { + return lines.some((line) => line.trim() === PATTERN); + } + + async function appendManagedEntry(excludePath: string): Promise { + let content = ''; + try { + content = await fs.readFile(excludePath, 'utf8'); + } catch { + // Missing `info/exclude` (or even `info/`) — created below. + } + if (hasPattern(content.split('\n'))) return; + await fs.mkdir(dirname(excludePath), { recursive: true }); + const separator = content.length === 0 || content.endsWith('\n') ? '' : '\n'; + await fs.writeFile(excludePath, `${content}${separator}${MARKER}\n${PATTERN}\n`); + } + + async function removeManagedEntry(excludePath: string): Promise { + let content: string; + try { + content = await fs.readFile(excludePath, 'utf8'); + } catch { + return; + } + const lines = content.split('\n'); + const kept: string[] = []; + for (let i = 0; i < lines.length; i++) + if (lines[i].trim() === MARKER && lines[i + 1]?.trim() === PATTERN) + i++; // Skip the marker and its pattern line — everything else survives. + else + kept.push(lines[i]); + const stripped = kept.join('\n'); + if (stripped !== content) await fs.writeFile(excludePath, stripped); + } + + /** Excludes the repository owning `generatedFilePath`, when the setting is + * on. Called after every settings-file generation/sync. */ + export async function ensureExcludedFor(generatedFilePath: string): Promise { + if (!isAutoIgnoreEnabled()) return; + try { + const excludePath = await findGitInfoExcludePath(dirname(generatedFilePath)); + if (excludePath) await appendManagedEntry(excludePath); + } catch (error) { + console.error(`[DDK] Failed to update .git/info/exclude for ${generatedFilePath}: ${error}`); + } + } + + /** Applies a settings toggle to the repositories of every managed project: + * append the entry when enabled, remove the DDK-managed entry when + * disabled. */ + export async function onSettingChanged(): Promise { + const enabled = isAutoIgnoreEnabled(); + const projectDirs = (Runtime.projectsData?.projects ?? []) + .map((project) => project.dproj || project.dpr || project.dpk) + .filter((source): source is string => !!source) + .map((source) => dirname(source)); + + const excludePaths = new Set(); + for (const dir of projectDirs) + try { + const excludePath = await findGitInfoExcludePath(dir); + if (excludePath) excludePaths.add(excludePath); + } catch { + // Unreachable directory — nothing to manage there. + } + + for (const excludePath of excludePaths) + try { + if (enabled) await appendManagedEntry(excludePath); + else await removeManagedEntry(excludePath); + } catch (error) { + console.error(`[DDK] Failed to update ${excludePath}: ${error}`); + } + } +} diff --git a/vscode_extension/src/delphilsp/mergedDiagnostics.ts b/vscode_extension/src/delphilsp/mergedDiagnostics.ts new file mode 100644 index 0000000..7f89849 --- /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?.isDelphiLspExtensionAvailable) 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); + } +} diff --git a/vscode_extension/src/runtime.ts b/vscode_extension/src/runtime.ts index e22fec1..a3552e7 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();