Skip to content
16 changes: 8 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -657,18 +657,18 @@ A variable's type is set at first assignment. Compatible types (int/float/bool/n

## Error messages

Errors include line and column numbers, and the compiler tries to recover far enough to report multiple independent syntax / semantic errors in one pass. Successful compilations may also emit non-fatal warnings such as unused variables / parameters or unreachable code:
Errors name the file, line and column they come from (the entry file as given on the command line, or the included or autoloaded file that holds the statement), and the compiler tries to recover far enough to report multiple independent syntax / semantic errors in one pass. Successful compilations may also emit non-fatal warnings such as unused variables / parameters or unreachable code:
Comment thread
greptile-apps[bot] marked this conversation as resolved.

```
error[3:1]: Undefined variable: $x
error[5:7]: Type error: cannot reassign $x from int to string
error[2:1]: Required file not found: 'missing.php'
warning[6:1]: $a changes type from int to string; the previous value is discarded (compile with --strict-locals to make this an error)
warning[9:5]: Unused variable: $tmp
warning[14:9]: Unreachable code
error[main.php:3:1]: Undefined variable: $x
error[main.php:5:7]: Type error: cannot reassign $x from int to string
error[main.php:2:1]: Required file not found: 'missing.php'
warning[main.php:6:1]: $a changes type from int to string; the previous value is discarded (compile with --strict-locals to make this an error)
warning[main.php:9:5]: Unused variable: $tmp
warning[src/Helpers.php:14:9]: Unreachable code
```

A diagnostic inside an included or autoloaded file also names that file, as in `error[/app/src/Broken.php:4:41]: Undefined class: App\Missing` (see [diagnostic locations](docs/compiling/output-and-diagnostics.md#diagnostic-locations)).
A diagnostic with no source position (some whole-program checks) prints `error: message`. See [diagnostic locations](docs/compiling/output-and-diagnostics.md#diagnostic-locations).

## Project structure

Expand Down
2 changes: 1 addition & 1 deletion docs/compiling/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -415,7 +415,7 @@ have run under:

```
$ elephc --php-version 8.2 app.php
error[3:5]: this program needs PHP 8.4 (property hooks), but --php-version selected 8.2; a binary built for 8.2 could not have run this source
error[app.php:3:5]: this program needs PHP 8.4 (property hooks), but --php-version selected 8.2; a binary built for 8.2 could not have run this source
```

Detected today: the pipe operator `|>` (8.5), property hooks and asymmetric
Expand Down
11 changes: 11 additions & 0 deletions docs/compiling/compilation-pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,17 @@ Physical source (.php or .lfc)
`func_get_arg()` into a hidden variadic parameter plus ordinary PHP operations.
This happens after autoloading but before manifest baking, optimization, and
checking, so those later passes see the desugared callable shape.
- **autoload prelude probe** — the autoload pass runs after name resolution, so
a prelude needed only by an autoloaded file would be missed. When autoload
rules exist, a probe (`src/autoload/prelude_probe.rs`) first runs name
resolution and the autoload pass on a copy of the program, asks each
prelude's detector about the result, and restores the thread-local source IDs
and anonymous-class counter. Process-global synthetic spans and include
temporaries stay monotonic, so discarding a probe cannot recycle identities
allocated by another compilation thread. The real pass injects the detected
preludes with fresh global identities. Every included and
autoloaded file gets its own source identity, which is how diagnostics name
the file they come from.
- **opcache-manifest-bake** — after autoload insertion and argument-introspection
desugaring, replaces the placeholder OPcache manifest with the complete
entry/include/autoload file set before constant folding and emits any preload
Expand Down
21 changes: 12 additions & 9 deletions docs/compiling/output-and-diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,19 +114,22 @@ and `--quiet` keep the compact plain output without progress lines.

### Diagnostic locations

Errors and warnings print as `error[line:col]: message` and
`warning[line:col]: message`. A diagnostic raised inside a file other than the
entry file (an `include`/`require` target, a Composer `autoload.files` entry, or
a class file loaded through PSR-4 or an SPL autoload rule) names that file
before the position, as the path it was loaded from:
Errors and warnings name the physical file, line and column they come from:

```text
error[3:1]: Undefined variable: $x
error[/app/src/Broken.php:4:41]: Undefined class: App\Missing
warning[/app/lib/helpers.php:9:5]: Unused variable: $tmp
error[main.php:3:1]: Undefined variable: $x
error[main.php:2:1]: Required file not found: 'missing.php'
warning[src/Helpers.php:14:9]: Unreachable code
```

Lexer and parser errors name their file in every case, the entry file included.
The file is the entry file as given on the command line, or the included or
autoloaded file that holds the statement, so a diagnostic in a large autoloaded
tree points at the right file. The entry path is registered before frontend and
autoload processing, so early autoload warnings name it too. A position whose
source file is not known (a compiler-synthesized node) prints `kind[line:col]`,
and a diagnostic with no source position at all (some whole-program checks)
prints `kind: message`.
Profile `note[…]` lines keep their `note[line:col]` form.

### `--quiet` / `-q`

Expand Down
2 changes: 1 addition & 1 deletion docs/internals/the-lexer.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ The cursor automatically tracks line and column — when it sees a `\n`, it incr

The entry file keeps identity 0. Every other physical file the compile loads takes the next identity from `Span::fresh_source_id()`: each include execution (`resolver::files::parse_file`), each Composer `autoload.files` entry and PSR-4 or SPL-rule class file (`autoload::load_autoloaded_file`), and the includes those class files pull in. Only the root include resolution (`resolver::resolve_collecting_includes_with_defines`) rewinds the counter with `Span::reset_source_ids`; an autoloaded file resolves its own includes through `resolve_collecting_includes_continuing`, which does not. Two files therefore never share an identity within one compile, so a statement at the same line and column of two class files is two distinct keys in span-keyed checker maps (`throw_access_sites`, `builtin_call_types`, `loop_storage_types`, the local binding decisions). Autoloaded files used to be lexed with identity 0, which handed one class's readonly write guard to a plain write at the same position in another class (issue #1445).

Each loader also records the file behind its identity with `Span::register_source_path`. A checker diagnostic carries only a span, and the pipeline tags lowering, export and `--emit-ir` refusals with the entry filename whatever file they come from, so the diagnostic printer (`errors::report`, `errors::report_warning`) asks `Span::source_path()` first and uses a tag only for a span with no registered source. An error inside an included or autoloaded file therefore prints as `error[path:line:col]`. The entry file is never registered, so its diagnostics keep the `error[line:col]` form. The registry is per thread, like the identity counter it names.
Each loader also records the file behind its identity with `Span::register_source_path`. A checker diagnostic carries only a span, and the pipeline tags lowering, export and `--emit-ir` refusals with the entry filename whatever file they come from, so the diagnostic printer (`errors::report`, `errors::report_warning`) asks `Span::source_path()` first and uses a tag only for a span with no registered source. An error inside an included or autoloaded file therefore prints as `error[path:line:col]`. The pipeline registers the entry file as identity 0 before frontend and autoload processing, so its diagnostics also name that file. The registry is per thread, like the identity counter it names.

A literal `eval` fragment parsed at EIR lowering carries its own identity from `Span::fresh_synthetic_source_id()`: a counter separate from the include one that `Span::reset_source_ids` rewinds at the start of every compile, and starting above the packed identity range. A fragment span therefore cannot alias the root file's or an included file's coordinates in span-keyed maps such as `builtin_call_types`.

Expand Down
15 changes: 15 additions & 0 deletions docs/internals/the-type-checker.md
Original file line number Diff line number Diff line change
Expand Up @@ -736,6 +736,21 @@ After successful checking, elephc also runs a warning pass over the AST (`src/ty

Warnings are returned through `CheckResult` and printed by the CLI without failing the compilation.

Checker errors and warnings carry only a `Span`. Each file the resolver or the autoload pass lexes gets its own source identity, and `Span::register_source_path` records which physical file that identity came from; `print_error` / `print_warning` prefer `Span::source_path` over an explicit file tag, so an entry-file tag cannot rename a position in another file. The pipeline registers the entry path before frontend processing, making positioned diagnostics print as `kind[file:line:col]`. Class schema errors (circular inheritance, extending a `final` class, duplicate or unknown parent classes) point at the class declaration.

The discarded autoload prelude probe restores only thread-local source IDs and anonymous-class numbering. Synthetic spans and hidden value-include temporaries use monotonic process-global counters, so a probe cannot recycle identities allocated by another compiler thread. Gaps left by discarded work do not affect PHP values or name resolution.

### Lookup indexes

Large programs (thousands of classes) made several per-member scans of the whole class table dominate checking time. The checker keeps lazily built indexes for them, each stored with the `classes.len()` it was built for, so registering a class invalidates it:

- `class_children_index`: direct subclasses per parent, used by `class_and_descendants` for late-bound `new static` targets.
- `method_impl_users_index`: the classes whose method table dispatches a given `(implementing class, method, is_static)` to that implementation, to which each checked body's inferred return is copied.
- `property_sharers_index`: the classes sharing a `(property, declaring class)` slot, retyped together by a constructor argument.
- `loop_storage_scope_spans`: the loop spans `loop_storage_types` holds for each scope, so re-walking a scope clears only its own loop contracts.

Builtin, function and folded-map lookups try the exact key before the case-insensitive scan.

## Where the checker sits in the optimizer pipeline

The type checker sits between an early folding pass and four post-check cleanup passes in `src/optimize/`:
Expand Down
6 changes: 3 additions & 3 deletions docs/php/types.md
Original file line number Diff line number Diff line change
Expand Up @@ -533,11 +533,11 @@ These standard PHP filesystem functions are intentionally absent from elephc bec

### Compiler diagnostics

elephc reports errors with source spans. Example:
elephc reports errors with the file, line and column they come from. Example:

```text
error[3:5]: Undefined variable: $name
error[8:1]: Function 'foo' declared return type string but returns int
error[main.php:3:5]: Undefined variable: $name
error[src/Helpers.php:8:1]: Function 'foo' declared return type string but returns int
```

The compiler also emits non-fatal warnings (unused variables, unreachable code).
Expand Down
1 change: 1 addition & 0 deletions src/autoload/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

mod alias;
mod dev_packages;
pub mod prelude_probe;
mod index;
mod interpret;
mod registry;
Expand Down
77 changes: 77 additions & 0 deletions src/autoload/prelude_probe.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
//! Purpose:
//! Decides which detected standard-library preludes the AUTOLOAD CLOSURE of a program needs,
//! before those preludes are injected.
//!
//! Called from:
//! - `crate::pipeline::compile()`, right before the prelude injections.
//!
//! Key details:
//! - Each prelude (PDO, mysqli, tz, image, hash, curl, xml, var_export) is injected only when
//! the program uses its surface, and that decision has to be taken before name resolution so a
//! namespaced caller resolves to the injected declarations. The Composer/SPL autoload pass runs
//! AFTER name resolution, so a class file it loads that uses `PDO` or `CurlHandle` was never
//! scanned: the prelude was skipped and the program failed with `Unknown type: PDO`.
//! - The probe runs name resolution and the autoload pass on a COPY of the program, scans the
//! result with each prelude's own detector, and hands back the groups found. The real pass then
//! injects them exactly as if the entry file had used them, so the outcome is the one
//! `--with-<crate>` gives, without forcing the whole surface past reachability pruning.
//! - The copy's work is discarded, restoring thread-local source IDs and anonymous-class
//! numbers so the real parse keeps those identities. Process-global synthetic spans and
//! include temporaries remain monotonic, preserving identities other threads allocated.
//! - A probe failure is not reported here: the real pass hits the same error and reports it.

use std::collections::HashSet;
use std::path::Path;

use crate::parser::ast::{Program, Stmt};
use crate::span::Span;

use super::Registry;

#[cfg(test)]
#[path = "prelude_probe_tests.rs"]
mod tests;

/// Every prelude group the probe can report, with the detector that decides it.
const PROBED_GROUPS: &[(&str, fn(&[Stmt]) -> bool)] = &[
("pdo", crate::pdo_prelude::program_uses_pdo),
("mysqli", crate::mysqli_prelude::program_uses_mysqli),
("tz", crate::tz_prelude::program_uses_surface),
("image", crate::image_prelude::program_uses_surface),
("hash", crate::hash_prelude::program_uses_surface),
("curl", crate::curl_prelude::program_uses_surface),
("xml", crate::xml_prelude::program_uses_surface),
("var_export", crate::var_export_prelude::program_uses_surface),
];

/// Returns the prelude groups some file of the autoload closure uses.
///
/// `program` is the include-resolved entry program, before any prelude is injected. Returns an
/// empty set when nothing is autoloadable or the probe cannot finish.
pub fn autoloaded_prelude_groups(
program: &Program,
base_dir: &Path,
registry: &Registry,
defines: &HashSet<String>,
) -> HashSet<&'static str> {
if registry.is_empty() {
return HashSet::new();
}
let span_counters = Span::counters_checkpoint();
let anonymous_classes = crate::parser::anonymous_class_counter();
let closure = crate::name_resolver::resolve(program.clone())
.ok()
.and_then(|resolved| {
super::run_collecting_included_with_defines(resolved, base_dir, registry, defines).ok()
});
Span::restore_counters(span_counters);
crate::parser::restore_anonymous_class_counter(anonymous_classes);
let Some((closure, _)) = closure else {
return HashSet::new();
};
PROBED_GROUPS
.iter()
.filter(|(_, detect)| detect(&closure))
Comment thread
greptile-apps[bot] marked this conversation as resolved.
.map(|(group, _)| *group)
.collect()
}
101 changes: 101 additions & 0 deletions src/autoload/prelude_probe_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
//! Purpose:
//! Protects the identity allocation boundary between discarded probes and real autoloads.
//!
//! Called from:
//! - `autoload::prelude_probe` unit tests.
//!
//! Key details:
//! - Source and anonymous-class IDs are restored, while global include temporaries stay fresh.
//! - The fixture removes its files even when an assertion fails.

use std::fs;
use std::path::PathBuf;

use crate::parser::ast::{Program, StmtKind};
use crate::span::Span;

use super::{autoloaded_prelude_groups, Registry};

/// Owns the small Composer project used to exercise a real probe and autoload pass.
struct ProbeProject(PathBuf);

impl ProbeProject {
/// Creates an autoloaded class with a value include and an anonymous class.
fn new() -> Self {
let path = std::env::temp_dir().join(format!(
"elephc_probe_counter_{}_{:?}", std::process::id(), std::thread::current().id(),
));
fs::create_dir(&path).unwrap();
let project = Self(path);
fs::create_dir(project.0.join("src")).unwrap();
fs::write(project.0.join("composer.json"),
r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#).unwrap();
fs::write(project.0.join("src/value.php"), "<?php return 7;").unwrap();
fs::write(project.0.join("src/Probe.php"), r#"<?php
namespace App;
$value = require __DIR__ . '/value.php';
$object = new class {};
class Probe { public ?\PDO $pdo = null; }
"#).unwrap();
project
}
}

impl Drop for ProbeProject {
/// Cleans up the fixture on both success and unwinding.
fn drop(&mut self) {
fs::remove_dir_all(&self.0).unwrap();
}
}

/// Parses the entry or seed program without allocating a physical-file identity.
fn parse(source: &str) -> Program {
crate::parser::parse(&crate::lexer::tokenize(source).unwrap()).unwrap()
}

/// Reads the resolver's hidden include temporary from the flattened top-level statements.
fn include_temporary_id(program: &[crate::parser::ast::Stmt]) -> Option<usize> {
program.iter().find_map(|stmt| match &stmt.kind {
StmtKind::Assign { name, .. } => name.strip_prefix("__elephc_inc_")?.parse().ok(),
StmtKind::NamespaceBlock { body, .. } | StmtKind::Synthetic(body) => {
include_temporary_id(body)
}
_ => None,
})
}

/// A probe preserves real-file and anonymous-class naming without recycling its include temporary.
#[test]
fn probe_restores_local_identities_but_keeps_include_temporaries_monotonic() {
let project = ProbeProject::new();
let defines = std::collections::HashSet::new();
let seed = crate::resolver::resolve_collecting_includes_continuing(
parse("<?php $seed = require 'src/value.php';"), &project.0, &defines,
).unwrap().0;
let previous_temporary = include_temporary_id(&seed).expect("seed include temporary");
let (registry, program) = Registry::build(&project.0, parse("<?php new App\\Probe();"));
assert!(!registry.is_empty());
let anonymous_class = crate::parser::anonymous_class_counter();
let checkpoint = Span::counters_checkpoint();
let source_id = Span::fresh_source_id();
let synthetic_source_id = Span::fresh_synthetic_source_id();
Span::restore_counters(checkpoint);

let groups = autoloaded_prelude_groups(&program, &project.0, &registry, &defines);

assert!(groups.contains("pdo"));
assert_eq!(crate::parser::anonymous_class_counter(), anonymous_class);
assert_eq!(Span::fresh_source_id(), source_id);
assert_eq!(Span::fresh_synthetic_source_id(), synthetic_source_id);
let resolved = crate::name_resolver::resolve(program).unwrap();
let (loaded, _) = crate::autoload::run_collecting_included_with_defines(
resolved, &project.0, &registry, &defines,
).unwrap();
let actual_temporary = include_temporary_id(&loaded).expect("real autoload include temporary");
assert!(actual_temporary > previous_temporary + 1,
"the probe's discarded include temporary must not be reused");
let expected_anonymous = format!("class@anonymous#{anonymous_class}");
assert!(loaded.iter().any(|stmt| matches!(&stmt.kind,
StmtKind::ClassDecl { name, .. } if name.ends_with(&expected_anonymous))),
"the real parse must retain its anonymous-class name");
}
8 changes: 8 additions & 0 deletions src/curl_prelude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1924,6 +1924,14 @@ function curl_share_init_persistent(array $share_options): CurlSharePersistentHa
// -- elephc PHP >= 8.5 curl_share_init_persistent end --
"#;

/// Whether `program` uses this prelude's surface: the same test `inject_if_used` applies.
///
/// Also run by `crate::autoload::prelude_probe` over the autoload closure, whose files are only
/// loaded after this prelude has been decided.
pub(crate) fn program_uses_surface(program: &[crate::parser::ast::Stmt]) -> bool {
detect::program_uses_curl(program)
}

/// Injects the curl prelude when the program references the `ext/curl` surface, leaving
/// every other program untouched, for the DEFAULT PHP compatibility version.
///
Expand Down
Loading
Loading