diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1e3b7ef7f..afe3a63c1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,13 @@ jobs: printf '[target.%s]\nrustflags = ["-C", "link-arg=-fuse-ld=mold"]\n' "$CARGO_BUILD_TARGET" >> .cargo/config.toml - run: cargo fmt --check - run: cargo clippy --all-targets -- -D warnings + # `semantic-export` is off by default, so nothing above compiles it. + # Without these steps a refactor of an internal API breaks the module + # and no job notices until someone builds with the feature on. They + # run here rather than in the test job so that job keeps measuring + # exactly the feature set the released binary is built with. + - run: cargo clippy --all-targets --features semantic-export -- -D warnings + - run: cargo test --features semantic-export --lib semantic_export test: name: Test diff --git a/Cargo.toml b/Cargo.toml index e5d6299b3..7ecf1a271 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -85,6 +85,12 @@ mimalloc = ["dep:mimalloc", "dep:libmimalloc-sys", "dep:libc"] # installs a counting global allocator that adds two atomics to every # allocation, so it must never be enabled in a shipped build. mem-audit = [] +# Owned semantic records for non-LSP consumers. Kept out of default builds so +# the public surface and its code have zero cost for editor users. +semantic-export = [] +# Prevent the build script from downloading phpstorm-stubs. Existing local +# stubs are still embedded; a build without them gets an empty stub index. +offline-stubs = [] # Download size is what matters in the browser, so the wasm module is built for # size rather than speed. A separate profile keeps this away from the native diff --git a/build.rs b/build.rs index bae0c0e48..c7686b320 100644 --- a/build.rs +++ b/build.rs @@ -100,6 +100,7 @@ fn main() { println!("cargo:rerun-if-changed=."); println!("cargo:rerun-if-changed=build.rs"); println!("cargo:rerun-if-changed=stubs.lock"); + println!("cargo:rerun-if-env-changed=CARGO_FEATURE_OFFLINE_STUBS"); // Re-run when HEAD moves (commit, checkout, tag) so the embedded // version string stays current. println!("cargo:rerun-if-changed=.git/HEAD"); @@ -154,6 +155,15 @@ fn main() { } } + if needs_fetch && env::var_os("CARGO_FEATURE_OFFLINE_STUBS").is_some() { + eprintln!( + "cargo:warning=Stubs are unavailable or stale; building without them because the offline-stubs feature is enabled." + ); + println!("cargo:rustc-env=PHPANTOM_STUBS_VERSION=none"); + write_empty_stubs(); + return; + } + if needs_fetch { let stubs_path = stubs_root.join(STUBS_DIR_REL); if stubs_path.exists() { diff --git a/docs/BUILDING.md b/docs/BUILDING.md index 83bd15a05..80d617bd2 100644 --- a/docs/BUILDING.md +++ b/docs/BUILDING.md @@ -18,6 +18,29 @@ The stubs are downloaded on first build and cached in `stubs/`. To update to the For details on how symbol resolution and stub loading work, see [ARCHITECTURE.md](ARCHITECTURE.md). +### Optional features + +Both are off by default, so an editor build compiles neither. + +`offline-stubs` keeps the build script from reaching the network: when +the stubs are missing or stale it embeds an empty stub index and +continues instead of downloading them. Stubs already present in +`stubs/` are still embedded, so a machine that has built once keeps its +standard library. A build without them resolves only the project's own +code, so use it when the build must not make network calls rather than +to save time. + +`semantic-export` compiles `phpantom_lsp::semantic_export`, an API for +programs that embed PHPantom as a library rather than talking to it +over LSP. The caller supplies PHP documents as strings, and gets back +declarations, occurrences, calls, byte ranges, and diagnostics as owned +values that outlive the backend they came from. Nothing is read from +disk and no server is started. + +```bash +cargo build --features semantic-export,offline-stubs +``` + ### Matching the released binary The Linux binaries we publish are static musl builds using mimalloc as @@ -68,6 +91,8 @@ Before submitting changes, run exactly what CI runs: cargo test cargo clippy -- -D warnings cargo clippy --tests -- -D warnings +cargo clippy --all-targets --features semantic-export -- -D warnings +cargo test --features semantic-export --lib semantic_export cargo fmt --check find examples/php -name '*.php' -print0 | xargs -0 -n1 php -l php -d zend.assertions=1 examples/php/scaffolding/assertions.php @@ -80,7 +105,7 @@ CI additionally builds the WebAssembly target and runs if your change touches dependencies, `Cargo.toml`, or anything in the per-file request path. See [wasm.md](wasm.md). -All eight must pass with zero warnings and zero failures, except the +All ten must pass with zero warnings and zero failures, except the final `analyze` run: `app/Demo.php` carries three deliberate mistakes, each demonstrating a diagnostic. `Artisan::call('does:not-exist')` demonstrates `invalid_laravel_command`, and one `view('welcome', …)` diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 0b83bd81d..32ce2407e 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Call hierarchy.** Editors can now ask who calls a function or method and what it calls in turn, walking the tree outward one level at a time. Incoming calls group every call site under the function or method containing it, so a method reached from twenty places in one class lists that class once with twenty ranges rather than twenty separate entries. Outgoing calls resolve each callee through the same engine go-to-definition uses, including the constructor a `new` expression runs, so a call that crosses into another file lands on the declaration there. Naming a callable without invoking it, a read of a same-named property or a `@see` tag pointing at the method, is not a call and stays out of both directions. Contributed by @sidux. - **Reference CodeLens.** A clickable reference count sits above the classes, functions, methods, properties, and constants a PHP file declares, and following one opens the same list Find References gives. This replaces the count that used to be drawn at the end of the declaration line, which could only be read, so the number appears once and in a place you can act on. A declaration nothing in the workspace names is answered from the index straight away, and the receiver of every member access in a candidate file is resolved once and kept in a compact semantic layer, so opening a large file does not turn into one expensive search per lens. Clients that can be asked to re-pull lenses are shown a member's count once it is ready rather than being made to resolve each one in turn. A lens whose count is being worked out reads `- references` and keeps its line, so editing a file does not shuffle every line in it up and down as counts come and go, and an edit only counts again what it can actually have changed: typing inside a method body leaves every count in the file standing, and a burst of keystrokes is answered once rather than once per keystroke. The implementation count on an interface or abstract class is unchanged and still sits at the end of the declaration line. Contributed by @sidux. - **Fully-qualified PHP classes navigate from YAML and XML.** Ctrl+Click a class name in any YAML key or value, or any XML attribute or text node, and PHPantom opens its PHP declaration without needing to know that file's schema. `Class::member` references navigate too. The same occurrences feed Find References and declaration CodeLens through the workspace reference index. Unknown and unqualified strings are left alone. Contributed by @sidux. +- **Headless consumers can export owned semantic records without starting an LSP transport.** The optional `semantic-export` feature accepts caller-supplied PHP documents, resolves them together, and returns deterministic declarations, occurrences, calls, byte ranges, and document diagnostics through batch or streaming APIs. An independent `offline-stubs` feature guarantees that missing stubs do not trigger a build-time download. Contributed by @aaaaaandrew. - **`analyze` takes more than one path.** `phpantom_lsp analyze app/ lib/Helper.php tests/` scans the union of everything named, mixing directories and single files freely, so a pre-commit hook or a CI step can hand it exactly the paths that changed instead of running the whole project or invoking the binary once per path. Overlapping arguments are reported once, and a path that does not exist still stops the run with exit code 2. Naming no path scans the entire project, as before. - **Blade directives a project registers itself.** A directive declared with `Blade::directive('priceTag', …)` or `Blade::if('bakeryOpen', …)` in a service provider is now read off that registration, so a template writing it gets the same treatment as one writing a directive Blade ships: the name is offered while it is being typed, and the expression the directive is handed stays real PHP whose types are checked, instead of the whole thing being masked as markup. `Blade::if()` registers four directives rather than one, and all four (`@bakeryOpen`, `@unlessbakeryOpen`, `@elsebakeryOpen`, `@endbakeryOpen`) are recognised as the block they form. A directive registered while the editor is open applies to the templates already open. - **Your editor's own file settings now reach the index.** PHPantom accepts the `[indexing]` exclude and extension lists from the editor as well as from `.phpantom.toml`, so the folders you already hide and the extensions you already open as PHP no longer have to be written out a second time. In VS Code and Cursor this needs no setup: the extension reads `files.exclude` and `files.associations` for each workspace folder, translates them, and re-sends them when you change either. Zed and other clients can supply the same block through their language-server initialization options, documented under [Editor Setup](editor-setup.md). A change made mid-session is reconciled against the index that was already built, whether it comes from the editor or from a live `.phpantom.toml` edit: classes under a path you just hid leave the index, and files that a lifted exclude or a newly named extension brings into scope are picked up by a rescan a moment later, so neither direction waits for a restart. A file you have open is always served, whatever the filters say about it. Editor settings and `.phpantom.toml` are two layers of one filter set rather than one overriding the other: both lists apply, changing one never drops the other, and a `!` re-include in the project's config still wins over a path an individual contributor happens to hide. diff --git a/src/lib.rs b/src/lib.rs index 13af462b2..d2434bf3d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -284,6 +284,8 @@ pub(crate) mod scope_collector; mod selection_range; #[cfg(not(target_arch = "wasm32"))] pub mod self_update; +#[cfg(feature = "semantic-export")] +pub mod semantic_export; mod semantic_tokens; mod server; mod signature_help; diff --git a/src/semantic_export.rs b/src/semantic_export.rs new file mode 100644 index 000000000..fac3f0e6a --- /dev/null +++ b/src/semantic_export.rs @@ -0,0 +1,1453 @@ +//! Owned semantic records for headless, non-LSP consumers. +//! +//! Enable the `semantic-export` Cargo feature to compile this module. Callers +//! supply every document as source text; the exporter performs no workspace +//! discovery and starts no language-server transport. + +use std::collections::{BTreeMap, HashSet}; +use std::error::Error; +use std::fmt; +use std::path::PathBuf; +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; + +use crate::Backend; +use crate::inheritance::ancestry::find_declaring_ancestor; +use crate::symbol_map::{ + ClassRefContext, MappedSource, SelfStaticParentKind, SymbolKind, SymbolMap, +}; +use crate::types::{ClassInfo, ClassLikeKind, FileContext, FunctionInfo}; + +const RESOLVED_CLASS_CACHE_WINDOW: usize = 512; +const VARIABLE_DEFINITION_INDEX_THRESHOLD: usize = 16; +type MemberTargetMemo = BTreeMap<(String, String, OccurrenceKind), Option>; + +/// One PHP document supplied by the caller. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SourceDocument { + /// Stable document URI used to identify and cross-reference the source. + pub uri: String, + /// Complete UTF-8 PHP source text. + pub source: String, +} + +/// Invalid caller input rejected before any document is exported. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SemanticExportError { + /// More than one source used the same URI. + DuplicateUri(String), +} + +impl fmt::Display for SemanticExportError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::DuplicateUri(uri) => write!(formatter, "duplicate source document URI: {uri}"), + } + } +} + +impl Error for SemanticExportError {} + +/// A half-open byte range in a document's UTF-8 source text. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub struct ByteRange { + /// Inclusive byte offset. + pub start: u32, + /// Exclusive byte offset. + pub end: u32, +} + +/// Kind of exported declaration. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub enum DeclarationKind { + /// Class declaration. + Class, + /// Interface declaration. + Interface, + /// Trait declaration. + Trait, + /// Enum declaration. + Enum, + /// Enum case declaration. + EnumCase, + /// Standalone function declaration. + Function, + /// Class method declaration. + Method, + /// Class property declaration. + Property, + /// Global or class constant declaration. + Constant, +} + +/// Kind of relationship declared by a class-like symbol. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub enum RelationshipKind { + /// Class inheritance. + Extends, + /// Interface implementation or inheritance. + Implements, + /// Trait use. + UsesTrait, +} + +/// An owned relationship to another class-like symbol. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub struct ExportRelationship { + /// Relationship kind. + pub kind: RelationshipKind, + /// Fully-qualified target name. + pub target: String, +} + +/// An owned declaration record. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ExportDeclaration { + /// Declaration kind. + pub kind: DeclarationKind, + /// Name as declared. + pub name: String, + /// Fully-qualified symbol, using `Class::member` for class members. + pub symbol: String, + /// Owning class symbol for members. + pub owner: Option, + /// Range of the declared name token. + pub range: ByteRange, + /// Effective declared type, when available. + pub type_annotation: Option, + /// Human-readable docblock description, when available. + pub documentation: Option, + /// Declared class relationships. + pub relationships: Vec, +} + +/// Kind of exported occurrence. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub enum OccurrenceKind { + /// Class-like name. + Class, + /// Standalone function name. + Function, + /// Method name. + Method, + /// Property name. + Property, + /// Constant name. + Constant, + /// Local variable name. + Variable, + /// Imported symbol. + Import, + /// Type annotation. + Type, +} + +/// An owned symbol occurrence. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ExportOccurrence { + /// Occurrence kind. + pub kind: OccurrenceKind, + /// Range of the occurrence token. + pub range: ByteRange, + /// Name as written in source. + pub name: String, + /// Fully-qualified target when PHPantom resolved one. + pub resolved_symbol: Option, + /// Whether this occurrence is a declaration site. + pub is_definition: bool, +} + +/// Kind of call expression. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub enum CallKind { + /// Standalone function call. + Function, + /// Instance method call. + Method, + /// Static method call. + StaticMethod, + /// Class instantiation. + Constructor, +} + +/// An owned call record. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ExportCall { + /// Call kind. + pub kind: CallKind, + /// Normalized call expression used by PHPantom's resolver. + pub expression: String, + /// Resolved function, class, or `Class::method` symbol. + pub resolved_symbol: Option, + /// Range inside the call's parentheses. + pub arguments_range: ByteRange, + /// Ranges of argument expressions in source order. + pub arguments: Vec, +} + +/// Kind of document diagnostic emitted at the export boundary. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub enum ExportDiagnosticKind { + /// PHP source could not be parsed completely. + ParseError, + /// A class-like name could not be resolved. + UnresolvedClass, + /// A standalone function name could not be resolved. + UnresolvedFunction, + /// A member target or receiver could not be resolved. + UnresolvedMember, + /// A global constant name could not be resolved. + UnresolvedConstant, +} + +/// An owned document diagnostic. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ExportDiagnostic { + /// Diagnostic category. + pub kind: ExportDiagnosticKind, + /// Source range associated with the diagnostic. + pub range: ByteRange, + /// Human-readable message. + pub message: String, +} + +/// All semantic records for one supplied document. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ExportDocument { + /// URI supplied with the source document. + pub uri: String, + /// Sorted declarations. + pub declarations: Vec, + /// Sorted symbol occurrences. + pub occurrences: Vec, + /// Sorted call expressions. + pub calls: Vec, + /// Sorted parse and resolution diagnostics. + pub diagnostics: Vec, +} + +/// Materialized result returned by the batch API. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ExportBatch { + /// Documents sorted by URI. + pub documents: Vec, +} + +/// Reusable configuration for semantic export. +/// +/// The configuration is reusable, the work is not: every export call +/// builds its own project, including the standard-library index, and +/// drops it again. Hand each call as many documents as resolve together +/// rather than calling it once per file. +pub struct SemanticExporter { + workspace_root: PathBuf, +} + +impl SemanticExporter { + /// Create an exporter rooted at `workspace_root`. + /// + /// The root provides project context only. Export never discovers or + /// reads source files from it; all PHP documents must be supplied by the + /// caller. + #[must_use] + pub fn new(workspace_root: impl Into) -> Self { + Self { + workspace_root: workspace_root.into(), + } + } + + /// Export all documents into one owned batch. + /// + /// This is a convenience wrapper over [`Self::export_stream`]. + pub fn export(&self, sources: I) -> Result + where + I: IntoIterator, + { + let mut documents = Vec::new(); + self.export_stream(sources, |document| documents.push(document))?; + Ok(ExportBatch { documents }) + } + + /// Export one deterministic owned document at a time. + /// + /// Every source is registered in one shared backend before the first + /// callback, so cross-document resolution is identical to batch export. + /// The callback is invoked in URI order. + pub fn export_stream(&self, sources: I, consume: F) -> Result<(), SemanticExportError> + where + I: IntoIterator, + F: FnMut(ExportDocument), + { + self.export_stream_with_cache_window(sources, RESOLVED_CLASS_CACHE_WINDOW, consume) + } + + fn export_stream_with_cache_window( + &self, + sources: I, + cache_window: usize, + mut consume: F, + ) -> Result<(), SemanticExportError> + where + I: IntoIterator, + F: FnMut(ExportDocument), + { + let mut sources: Vec<_> = sources.into_iter().collect(); + sources.sort_by(|left, right| left.uri.cmp(&right.uri)); + if let Some(duplicate) = sources.windows(2).find(|pair| pair[0].uri == pair[1].uri) { + return Err(SemanticExportError::DuplicateUri(duplicate[0].uri.clone())); + } + + let backend = Backend::new_headless(); + *backend.workspace.workspace_root.write() = Some(self.workspace_root.clone()); + + for source in &sources { + backend.update_ast(&source.uri, &source.source); + } + + for (index, source) in sources.into_iter().enumerate() { + consume(export_document(&backend, &source)); + if cache_window != 0 && index.saturating_add(1) % cache_window == 0 { + backend.resolved_class_cache.write().clear(); + } + } + Ok(()) + } +} + +fn export_document(backend: &Backend, source: &SourceDocument) -> ExportDocument { + let _parse_guard = crate::parser::with_parse_cache(&source.source); + let _class_guard = + crate::virtual_members::with_active_resolved_class_cache(&backend.resolved_class_cache); + let _chain_guard = crate::type_engine::resolver::with_chain_resolution_cache(); + let _resolver_guard = crate::type_engine::call_resolution::activate_type_engine_caches(); + + let context = backend.file_context(&source.uri); + let symbol_map = backend.symbol_maps.read().get(&source.uri).cloned(); + let mut declarations = export_declarations(backend, source, &context, symbol_map.as_deref()); + let mut output = SpanExportOutput::default(); + + if let Some(map) = symbol_map.as_deref() + && let Some(mapped_source) = map.source(&source.source) + { + let variable_definition_offsets = + (map.var_defs.len() > VARIABLE_DEFINITION_INDEX_THRESHOLD).then(|| { + map.var_defs + .iter() + .map(|definition| definition.offset) + .collect() + }); + let export_context = SpanExportContext { + backend, + source, + mapped_source, + file_context: &context, + variable_definitions: &map.var_defs, + variable_definition_offsets: variable_definition_offsets.as_ref(), + }; + for span in &map.spans { + export_span(&export_context, span, &mut output); + } + } + + if let Some(errors) = backend.parse_errors.read().get(&source.uri) { + output + .diagnostics + .extend(errors.iter().map(|(message, start, end)| ExportDiagnostic { + kind: ExportDiagnosticKind::ParseError, + range: ByteRange { + start: *start, + end: (*end).max(*start), + }, + message: message.clone(), + })); + } + + let mut calls = symbol_map.as_deref().map_or_else(Vec::new, |map| { + export_calls(backend, source, &context, map, &mut output.member_targets) + }); + let SpanExportOutput { + mut occurrences, + mut diagnostics, + .. + } = output; + + declarations.sort_by(|left, right| { + left.range + .cmp(&right.range) + .then_with(|| left.symbol.cmp(&right.symbol)) + .then_with(|| left.kind.cmp(&right.kind)) + }); + declarations.dedup(); + occurrences.sort_by(|left, right| { + left.range + .cmp(&right.range) + .then_with(|| left.resolved_symbol.cmp(&right.resolved_symbol)) + .then_with(|| left.kind.cmp(&right.kind)) + }); + occurrences.dedup(); + calls.sort_by(|left, right| { + left.arguments_range + .cmp(&right.arguments_range) + .then_with(|| left.expression.cmp(&right.expression)) + }); + calls.dedup(); + diagnostics.sort_by(|left, right| { + left.range + .cmp(&right.range) + .then_with(|| left.kind.cmp(&right.kind)) + .then_with(|| left.message.cmp(&right.message)) + }); + diagnostics.dedup(); + + ExportDocument { + uri: source.uri.clone(), + declarations, + occurrences, + calls, + diagnostics, + } +} + +fn export_declarations( + backend: &Backend, + source: &SourceDocument, + context: &FileContext, + symbol_map: Option<&SymbolMap>, +) -> Vec { + let mut declarations = Vec::new(); + for class in &context.classes { + export_class(&source.source, class, &mut declarations); + } + + let globals = backend + .symbols + .uri_globals_index + .read() + .get(&source.uri) + .cloned() + .unwrap_or_default(); + for fqn in &globals.0 { + if let Some(function) = function_declared_by(backend, fqn, &source.uri) { + declarations.push(export_function(&source.source, fqn, &function)); + } + } + if let Some(map) = symbol_map { + for span in &map.spans { + let SymbolKind::ConstantReference { + name, + is_definition: true, + } = &span.kind + else { + continue; + }; + declarations.push(ExportDeclaration { + kind: DeclarationKind::Constant, + name: name.to_string(), + symbol: declared_constant_symbol( + &source.source, + context, + ByteRange { + start: span.start, + end: span.end, + }, + name, + ), + owner: None, + range: ByteRange { + start: span.start, + end: span.end, + }, + type_annotation: None, + documentation: None, + relationships: Vec::new(), + }); + } + } + declarations +} + +fn function_declared_by(backend: &Backend, fqn: &str, uri: &str) -> Option { + if let Some((declaring_uri, function)) = backend.symbols.global_functions.read().get(fqn) + && declaring_uri == uri + { + return Some(function.clone()); + } + backend + .symbols + .duplicate_functions + .read() + .get(fqn) + .and_then(|declarations| declarations.get(uri)) + .cloned() +} + +fn export_function(source: &str, fqn: &str, function: &FunctionInfo) -> ExportDeclaration { + ExportDeclaration { + kind: DeclarationKind::Function, + name: function.name.to_string(), + symbol: fqn.to_string(), + owner: None, + range: token_range(source, function.name_offset, function.name.as_ref(), false), + type_annotation: function.return_type.as_ref().map(ToString::to_string), + documentation: function.description.clone(), + relationships: Vec::new(), + } +} + +fn export_class(source: &str, class: &ClassInfo, output: &mut Vec) { + let fqn = class.fqn().to_string(); + let mut relationships = Vec::new(); + if let Some(parent) = class.parent_class { + relationships.push(ExportRelationship { + kind: RelationshipKind::Extends, + target: parent.to_string(), + }); + } + relationships.extend(class.interfaces.iter().map(|target| ExportRelationship { + kind: RelationshipKind::Implements, + target: target.to_string(), + })); + relationships.extend(class.used_traits.iter().map(|target| ExportRelationship { + kind: RelationshipKind::UsesTrait, + target: target.to_string(), + })); + relationships.sort(); + relationships.dedup(); + + let class_start = find_token_between( + source, + class.keyword_offset, + class.start_offset, + class.name.as_ref(), + ); + output.push(ExportDeclaration { + kind: match class.kind { + ClassLikeKind::Class => DeclarationKind::Class, + ClassLikeKind::Interface => DeclarationKind::Interface, + ClassLikeKind::Trait => DeclarationKind::Trait, + ClassLikeKind::Enum => DeclarationKind::Enum, + }, + name: class.name.to_string(), + symbol: fqn.clone(), + owner: None, + range: token_range(source, class_start, class.name.as_ref(), false), + type_annotation: None, + documentation: class.class_docblock.clone(), + relationships, + }); + + for method in class.methods.iter().filter(|method| !method.is_virtual) { + output.push(ExportDeclaration { + kind: DeclarationKind::Method, + name: method.name.to_string(), + symbol: format!("{fqn}::{}", method.name), + owner: Some(fqn.clone()), + range: token_range(source, method.name_offset, method.name.as_ref(), false), + type_annotation: method.return_type.as_ref().map(ToString::to_string), + documentation: method.description.clone(), + relationships: Vec::new(), + }); + } + for property in class + .properties + .iter() + .filter(|property| !property.is_virtual) + { + output.push(ExportDeclaration { + kind: DeclarationKind::Property, + name: property.name.to_string(), + symbol: format!("{fqn}::{}", property.name), + owner: Some(fqn.clone()), + range: token_range(source, property.name_offset, property.name.as_ref(), true), + type_annotation: property.type_hint_str(), + documentation: property.description.clone(), + relationships: Vec::new(), + }); + } + for constant in class + .constants + .iter() + .filter(|constant| !constant.is_virtual) + { + output.push(ExportDeclaration { + kind: if constant.is_enum_case { + DeclarationKind::EnumCase + } else { + DeclarationKind::Constant + }, + name: constant.name.to_string(), + symbol: format!("{fqn}::{}", constant.name), + owner: Some(fqn.clone()), + range: token_range(source, constant.name_offset, constant.name.as_ref(), false), + type_annotation: constant.type_hint_str(), + documentation: constant.description.clone(), + relationships: Vec::new(), + }); + } +} + +struct SpanExportContext<'a> { + backend: &'a Backend, + source: &'a SourceDocument, + /// The document text paired with the map the spans came from, so a + /// range-backed member subject slices the revision it indexes. + mapped_source: MappedSource<'a>, + file_context: &'a FileContext, + variable_definitions: &'a [crate::symbol_map::VarDefSite], + variable_definition_offsets: Option<&'a HashSet>, +} + +impl SpanExportContext<'_> { + fn is_variable_definition(&self, offset: u32) -> bool { + self.variable_definition_offsets.map_or_else( + || { + self.variable_definitions + .iter() + .any(|definition| definition.offset == offset) + }, + |offsets| offsets.contains(&offset), + ) + } +} + +#[derive(Default)] +struct SpanExportOutput { + member_targets: MemberTargetMemo, + occurrences: Vec, + diagnostics: Vec, +} + +fn export_span( + export_context: &SpanExportContext<'_>, + span: &crate::symbol_map::SymbolSpan, + output: &mut SpanExportOutput, +) { + let backend = export_context.backend; + let source = export_context.source; + let context = export_context.file_context; + let range = ByteRange { + start: span.start, + end: span.end, + }; + match &span.kind { + SymbolKind::ClassReference { + name, + is_fqn, + context: class_context, + } => { + let written = name.to_string(); + let resolved = if *is_fqn { + backend.find_or_load_class(name.trim_start_matches('\\')) + } else { + let loader = backend.class_loader(context); + loader(name) + }; + let symbol = resolved.as_ref().map(|class| class.fqn().to_string()); + output.occurrences.push(ExportOccurrence { + kind: if *class_context == ClassRefContext::UseImport { + OccurrenceKind::Import + } else if *class_context == ClassRefContext::TypeHint { + OccurrenceKind::Type + } else { + OccurrenceKind::Class + }, + range, + name: written.clone(), + resolved_symbol: symbol, + is_definition: false, + }); + if resolved.is_none() + && !matches!( + class_context, + ClassRefContext::TypeOperatorOperand | ClassRefContext::DocblockSee + ) + { + output.diagnostics.push(ExportDiagnostic { + kind: ExportDiagnosticKind::UnresolvedClass, + range, + message: format!("unresolved class-like symbol `{written}`"), + }); + } + } + SymbolKind::ClassDeclaration { name } => { + if let Some(class) = enclosing_class(&context.classes, span.start) { + output.occurrences.push(ExportOccurrence { + kind: OccurrenceKind::Class, + range, + name: name.to_string(), + resolved_symbol: Some(class.fqn().to_string()), + is_definition: true, + }); + } + } + SymbolKind::FunctionCall { + name, + is_definition, + is_docblock_reference, + } => { + let loader = backend.function_loader(context); + let function = loader(name, span.start); + let symbol = function.as_ref().map(function_fqn); + output.occurrences.push(ExportOccurrence { + kind: OccurrenceKind::Function, + range, + name: name.to_string(), + resolved_symbol: symbol, + is_definition: *is_definition, + }); + if !is_definition && function.is_none() && !is_docblock_reference { + output.diagnostics.push(ExportDiagnostic { + kind: ExportDiagnosticKind::UnresolvedFunction, + range, + message: format!("unresolved function `{name}`"), + }); + } + } + SymbolKind::MemberAccess { + subject_text, + member_name, + is_static, + is_method_call, + docblock_ref, + is_array_callable, + .. + } => { + let kind = member_occurrence_kind(&source.source, range, *is_static, *is_method_call); + let subject = subject_text.as_str(export_context.mapped_source); + let target = MemberTargetResolver { + backend, + context, + source: &source.source, + memo: &mut output.member_targets, + } + .resolve(subject, *is_static, span.start, member_name, kind); + output.occurrences.push(ExportOccurrence { + kind, + range, + name: member_name.to_string(), + resolved_symbol: target.clone(), + is_definition: false, + }); + if target.is_none() && !docblock_ref.tolerates_missing_target() && !is_array_callable { + output.diagnostics.push(ExportDiagnostic { + kind: ExportDiagnosticKind::UnresolvedMember, + range, + message: format!("unresolved member `{member_name}` on `{subject}`"), + }); + } + } + SymbolKind::MemberDeclaration { name, is_static } => { + if let Some(class) = enclosing_class(&context.classes, span.start) { + let kind = declared_member_kind(class, name, *is_static); + output.occurrences.push(ExportOccurrence { + kind, + range, + name: name.to_string(), + resolved_symbol: Some(format!("{}::{name}", class.fqn())), + is_definition: true, + }); + } + } + SymbolKind::ConstantReference { + name, + is_definition, + } => { + let loader = backend.constant_loader(context); + let resolved = loader(name, span.start); + let symbol = if *is_definition { + Some(declared_constant_symbol( + &source.source, + context, + range, + name, + )) + } else { + resolved + .as_ref() + .map(|_| context.resolve_name_at(name, span.start)) + }; + output.occurrences.push(ExportOccurrence { + kind: OccurrenceKind::Constant, + range, + name: name.to_string(), + resolved_symbol: symbol, + is_definition: *is_definition, + }); + if !is_definition && resolved.is_none() { + output.diagnostics.push(ExportDiagnostic { + kind: ExportDiagnosticKind::UnresolvedConstant, + range, + message: format!("unresolved constant `{name}`"), + }); + } + } + SymbolKind::Variable { name } | SymbolKind::CompactVariable { name } => { + output.occurrences.push(ExportOccurrence { + kind: OccurrenceKind::Variable, + range, + name: name.to_string(), + resolved_symbol: None, + is_definition: export_context.is_variable_definition(span.start), + }); + } + SymbolKind::SelfStaticParent(keyword) => { + let class = enclosing_class(&context.classes, span.start); + let symbol = class.and_then(|class| match keyword { + SelfStaticParentKind::Parent => class.parent_class.map(|name| name.to_string()), + _ => Some(class.fqn().to_string()), + }); + output.occurrences.push(ExportOccurrence { + kind: OccurrenceKind::Class, + range, + name: self_reference_name(*keyword).to_string(), + resolved_symbol: symbol, + is_definition: false, + }); + } + _ => {} + } +} + +fn export_calls( + backend: &Backend, + source: &SourceDocument, + context: &FileContext, + map: &SymbolMap, + member_targets: &mut MemberTargetMemo, +) -> Vec { + map.call_sites + .iter() + .map(|call| { + let (kind, resolved_symbol) = resolve_call( + backend, + context, + &source.source, + &call.call_expression, + call.args_start, + member_targets, + ); + let arguments = call + .arg_offsets + .iter() + .enumerate() + .filter_map(|(index, start)| { + let end = call + .comma_offsets + .get(index) + .copied() + .unwrap_or(call.args_end); + let is_named = call + .named_arg_indices + .binary_search(&(index as u32)) + .is_ok(); + argument_expression_range(&source.source, *start, end, is_named) + }) + .collect(); + ExportCall { + kind, + expression: call.call_expression.clone(), + resolved_symbol, + arguments_range: ByteRange { + start: call.args_start, + end: call.args_end, + }, + arguments, + } + }) + .collect() +} + +fn resolve_call( + backend: &Backend, + context: &FileContext, + source: &str, + expression: &str, + offset: u32, + member_targets: &mut MemberTargetMemo, +) -> (CallKind, Option) { + if let Some(class_name) = expression.strip_prefix("new ") { + let loader = backend.class_loader(context); + return ( + CallKind::Constructor, + loader(class_name).map(|class| class.fqn().to_string()), + ); + } + if let Some((subject, method)) = expression.rsplit_once("->") { + return ( + CallKind::Method, + MemberTargetResolver { + backend, + context, + source, + memo: member_targets, + } + .resolve(subject, false, offset, method, OccurrenceKind::Method), + ); + } + if let Some((subject, method)) = expression.rsplit_once("::") { + return ( + CallKind::StaticMethod, + MemberTargetResolver { + backend, + context, + source, + memo: member_targets, + } + .resolve(subject, true, offset, method, OccurrenceKind::Method), + ); + } + let loader = backend.function_loader(context); + ( + CallKind::Function, + loader(expression, offset).as_ref().map(function_fqn), + ) +} + +struct MemberTargetResolver<'a> { + backend: &'a Backend, + context: &'a FileContext, + source: &'a str, + memo: &'a mut MemberTargetMemo, +} + +impl MemberTargetResolver<'_> { + fn resolve( + &mut self, + subject: &str, + is_static: bool, + offset: u32, + member_name: &str, + kind: OccurrenceKind, + ) -> Option { + let owner = resolve_member_owner( + self.backend, + self.context, + self.source, + subject, + is_static, + offset, + )?; + let key = (owner.clone(), member_name.to_string(), kind); + let declaring_owner = self + .memo + .entry(key) + .or_insert_with(|| { + resolve_declaring_member_owner( + self.backend, + self.context, + &owner, + member_name, + kind, + ) + }) + .clone(); + if let Some(declaring_owner) = declaring_owner { + return Some(format!("{declaring_owner}::{member_name}")); + } + + let loader = self.backend.class_loader(self.context); + let class = loader(&owner)?; + let resolved = crate::virtual_members::resolve_class_fully_maybe_cached( + &class, + &loader, + Some(&self.backend.resolved_class_cache), + ); + member_exists(&resolved, member_name, kind).then(|| format!("{owner}::{member_name}")) + } +} + +fn resolve_member_owner( + backend: &Backend, + context: &FileContext, + source: &str, + subject: &str, + is_static: bool, + offset: u32, +) -> Option { + let class_loader = backend.class_loader(context); + let function_loader = backend.function_loader(context); + let resolution_context = crate::type_engine::subject_resolution::SubjectResolutionCtx { + local_classes: &context.classes, + use_map: &context.use_map, + namespace: &context.namespace, + content: source, + class_loader: &class_loader, + backend: Some(backend), + function_loader: &function_loader, + }; + let resolved = crate::type_engine::subject_resolution::resolve_subject_type( + subject, + is_static, + offset, + &resolution_context, + )?; + let names = resolved.top_level_class_names(); + (names.len() == 1).then(|| names[0].trim_start_matches('\\').to_string()) +} + +/// The class that actually declares `member_name`, so an occurrence on a +/// subclass resolves to the prototype rather than to the receiver. +/// +/// The walk is the shared one every other feature uses, so the precedence +/// it applies (own members, then traits, then the parent chain, then +/// interfaces) stays in step with go-to-definition and hover. +fn resolve_declaring_member_owner( + backend: &Backend, + context: &FileContext, + owner: &str, + member_name: &str, + kind: OccurrenceKind, +) -> Option { + let class_loader = backend.class_loader(context); + let class = class_loader(owner)?; + let declares = |candidate: &ClassInfo| member_exists(candidate, member_name, kind); + if declares(&class) { + return Some(class.fqn().to_string()); + } + find_declaring_ancestor(&class, &class_loader, &declares) + .map(|(_, declaring)| declaring.fqn().to_string()) +} + +fn member_exists(class: &ClassInfo, member_name: &str, kind: OccurrenceKind) -> bool { + match kind { + OccurrenceKind::Method => class + .methods + .iter() + .any(|member| member.name.eq_ignore_ascii_case(member_name)), + OccurrenceKind::Property => class + .properties + .iter() + .any(|member| member.name.as_str() == member_name), + OccurrenceKind::Constant => class + .constants + .iter() + .any(|member| member.name.as_str() == member_name), + _ => false, + } +} + +fn member_occurrence_kind( + source: &str, + range: ByteRange, + is_static: bool, + is_method_call: bool, +) -> OccurrenceKind { + if is_method_call { + OccurrenceKind::Method + } else if is_static + && source + .get(range.start as usize..range.end as usize) + .is_some_and(|text| !text.starts_with('$')) + { + OccurrenceKind::Constant + } else { + OccurrenceKind::Property + } +} + +fn declared_member_kind(class: &ClassInfo, name: &str, is_static: bool) -> OccurrenceKind { + if class + .methods + .iter() + .any(|member| member.name.eq_ignore_ascii_case(name)) + { + OccurrenceKind::Method + } else if class + .properties + .iter() + .any(|member| member.name.as_str() == name) + { + OccurrenceKind::Property + } else if is_static { + OccurrenceKind::Constant + } else { + OccurrenceKind::Property + } +} + +fn declared_constant_symbol( + source: &str, + context: &FileContext, + range: ByteRange, + name: &str, +) -> String { + let is_define_string = source + .as_bytes() + .get(range.end as usize) + .is_some_and(|byte| matches!(byte, b'\'' | b'"')); + if is_define_string { + name.trim_start_matches('\\').to_string() + } else { + context.resolve_name_at(name, range.start) + } +} + +fn function_fqn(function: &FunctionInfo) -> String { + function.namespace.as_ref().map_or_else( + || function.name.to_string(), + |namespace| format!("{namespace}\\{}", function.name), + ) +} + +fn self_reference_name(kind: SelfStaticParentKind) -> &'static str { + match kind { + SelfStaticParentKind::Self_ => "self", + SelfStaticParentKind::Static => "static", + SelfStaticParentKind::Parent => "parent", + SelfStaticParentKind::This => "$this", + } +} + +fn enclosing_class(classes: &[Arc], offset: u32) -> Option<&ClassInfo> { + classes + .iter() + .filter(|class| class.decl_start_offset <= offset && offset <= class.end_offset) + .min_by_key(|class| class.end_offset.saturating_sub(class.decl_start_offset)) + .map(AsRef::as_ref) +} + +fn trim_range(source: &str, start: u32, end: u32) -> Option { + let mut start = usize::try_from(start).ok()?; + let mut end = usize::try_from(end).ok()?; + if start > end || end > source.len() { + return None; + } + while start < end && source.as_bytes()[start].is_ascii_whitespace() { + start += 1; + } + while end > start && source.as_bytes()[end - 1].is_ascii_whitespace() { + end -= 1; + } + Some(ByteRange { + start: u32::try_from(start).unwrap_or(u32::MAX), + end: u32::try_from(end).unwrap_or(u32::MAX), + }) +} + +fn argument_expression_range( + source: &str, + start: u32, + end: u32, + is_named: bool, +) -> Option { + let start = if is_named { + let start_index = usize::try_from(start).ok()?; + let end_index = usize::try_from(end).ok()?; + let argument = source.get(start_index..end_index)?; + let colon = argument.find(':')?; + u32::try_from(start_index.saturating_add(colon).saturating_add(1)).ok()? + } else { + start + }; + trim_range(source, start, end).filter(|range| range.start < range.end) +} + +fn token_range(source: &str, offset: u32, name: &str, includes_dollar: bool) -> ByteRange { + let start = usize::try_from(offset).unwrap_or(0).min(source.len()); + let expected = if includes_dollar { + format!("${name}") + } else { + name.to_string() + }; + let token_start = if source[start..].starts_with(&expected) { + start + } else { + source[start..] + .find(&expected) + .map_or(start, |relative| start.saturating_add(relative)) + }; + ByteRange { + start: u32::try_from(token_start).unwrap_or(u32::MAX), + end: u32::try_from(token_start.saturating_add(expected.len())).unwrap_or(u32::MAX), + } +} + +fn find_token_between(source: &str, start: u32, end: u32, token: &str) -> u32 { + let start = usize::try_from(start).unwrap_or(0).min(source.len()); + let end = usize::try_from(end) + .unwrap_or(source.len()) + .min(source.len()); + source[start..end] + .find(token) + .and_then(|relative| u32::try_from(start.saturating_add(relative)).ok()) + .unwrap_or_else(|| u32::try_from(start).unwrap_or(u32::MAX)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn source(uri: &str, text: &str) -> SourceDocument { + SourceDocument { + uri: uri.to_string(), + source: text.to_string(), + } + } + + fn document<'a>(batch: &'a ExportBatch, suffix: &str) -> &'a ExportDocument { + batch + .documents + .iter() + .find(|document| document.uri.ends_with(suffix)) + .unwrap() + } + + #[test] + fn resolves_across_documents_and_keeps_owned_results() { + let batch = SemanticExporter::new("/workspace") + .export([ + source( + "file:///workspace/use.php", + "name();", + ), + source( + "file:///workspace/User.php", + "shared();", + ), + ]) + .unwrap(); + + let use_document = document(&batch, "/use.php"); + assert!( + use_document + .calls + .iter() + .any(|call| call.resolved_symbol.as_deref() == Some("App\\Base::shared")) + ); + assert!(use_document.diagnostics.is_empty()); + } + + #[test] + fn a_trait_member_wins_over_the_same_name_in_a_parent() { + let batch = SemanticExporter::new("/workspace") + .export([ + source( + "file:///workspace/Base.php", + "run();", + ), + ]) + .unwrap(); + + assert!( + document(&batch, "/use.php") + .calls + .iter() + .any(|call| call.resolved_symbol.as_deref() == Some("App\\Runner::run")) + ); + } + + #[test] + fn malformed_document_is_reported_without_stopping_the_batch() { + let batch = SemanticExporter::new("/workspace") + .export([ + source("file:///workspace/broken.php", "go();", + ), + ]; + + assert_eq!( + exporter.export(sources.clone()).unwrap(), + exporter.export(sources).unwrap() + ); + } + + #[test] + fn duplicate_uris_are_rejected_before_streaming() { + let exporter = SemanticExporter::new("/workspace"); + let sources = [ + source("file:///workspace/a.php", " = call + .arguments + .iter() + .map(|range| &source_text[range.start as usize..range.end as usize]) + .collect(); + + assert_eq!(argument_texts, ["$value", "'x:y'"]); + } + + #[test] + fn duplicate_global_constants_are_exported_from_each_document() { + let batch = SemanticExporter::new("/workspace") + .export([ + source( + "file:///workspace/a.php", + "value();"), + )); + } + + let export_with_window = |window| { + let mut documents = Vec::new(); + exporter + .export_stream_with_cache_window(sources.clone(), window, |document| { + documents.push(document); + }) + .unwrap(); + documents + }; + + assert_eq!(export_with_window(0), export_with_window(1)); + assert_eq!(export_with_window(1), export_with_window(4)); + assert_eq!(export_with_window(4), export_with_window(512)); + } +}