Skip to main content

brink_compiler/
lib.rs

1//! Compiler for inkle's ink narrative scripting language.
2//!
3//! Orchestrates the full compilation pipeline: file discovery, parsing
4//! (`brink-syntax`), HIR lowering (`brink-ir`), semantic analysis
5//! (`brink-analyzer`), and codegen into the `brink-format` binary
6//! representation consumed by `brink-runtime`.
7//!
8//! ## The `test-util`-gated entry points (issue #2168)
9//!
10//! `brink_environment::compile(&Environment)` (#1306) is the ruled
11//! determinism boundary and the **sole production road** into compilation —
12//! `brink-cli` and `brink-web` both go through it. This crate's `compile`,
13//! `compile_path`, `compile_with_options`, and `compile_path_with_options`
14//! take a `read_file` closure (or read straight off disk) and bypass
15//! `Environment` entirely, so once stdlib source is mounted into the
16//! `Environment` manifest (#2080), anything reached through them will not
17//! see the stdlib — the story compiles and conventions silently do not
18//! classify.
19//!
20//! Every call site of these four functions is test/bench/example code
21//! compiling an inline or fixture ink source with no need for a real
22//! `Environment`. They stay available for exactly that under the
23//! `test-util` feature (off by default). `#[cfg(test)]` cannot do this job —
24//! the callers span separate integration-test crates that cannot see this
25//! crate's own `#[cfg(test)]`. A test/bench/example target that needs them
26//! opts in with a `dev-dependencies` edge enabling the feature, e.g.:
27//!
28//! The guarantee this actually gives: an external crates.io consumer, or
29//! any isolated `cargo check -p <crate>` build that does not resolve
30//! `brink-test-harness`, cannot reach these functions without opting in.
31//! It is **not** a guarantee inside a `--workspace` build of this repo —
32//! `brink-test-harness` takes `brink-compiler` with `features =
33//! ["test-util"]` as a **normal** (non-dev) dependency, because its own
34//! `[[bin]]` targets and `src/corpus.rs` call these functions
35//! unconditionally, not just under a test cfg. Cargo's feature unification
36//! then enables `test-util` for every crate sharing that `brink-compiler`
37//! instance across the whole workspace resolve, so a production fn added to
38//! e.g. `brink-cli` would still compile under `cargo check --workspace`.
39//! The CI job's isolated `-p brink-cli -p brink-web -p brink-lsp -p
40//! bevy-brink -p brink-environment` check (added alongside this note) is
41//! what actually proves the fence for those crates, since it never resolves
42//! `brink-test-harness`.
43//!
44//! ```toml
45//! [dev-dependencies]
46//! brink-compiler = { workspace = true, features = ["test-util"] }
47//! ```
48
49#[cfg(feature = "test-util")]
50mod driver;
51
52pub use brink_driver::{AnalysisOptions, Dialect, TypePolicy};
53pub use brink_ir::{DiagnosticCode, FileId, Severity};
54
55use brink_format::StoryData;
56use std::io;
57#[cfg(feature = "test-util")]
58use std::path::Path;
59
60/// A diagnostic resolved for consumption outside the compiler.
61///
62/// The internal [`Diagnostic`] keys a file by [`FileId`] — an interning index
63/// that is only meaningful inside the compiler instance that produced it and
64/// is not stable across recompiles. A consumer (an editor, a host integration,
65/// an LSP) cannot map that id back to a file on its own. `ResolvedDiagnostic`
66/// carries the file's `path` — byte-identical to the string the host used as
67/// the entry point / answered the `read_file` callback with — so a diagnostic
68/// can always be located. `file` is retained for in-result correlation only.
69///
70/// `range` is left as byte offsets into the file's source. Line/column
71/// resolution is deliberately not baked in: column units are consumer-specific
72/// (LSP uses UTF-16 code units, a terminal uses bytes or chars), and the
73/// consumer already holds the source text to resolve them in the unit it needs.
74#[derive(Debug, Clone)]
75pub struct ResolvedDiagnostic {
76    /// The file this diagnostic belongs to, keyed by its source path.
77    pub path: String,
78    /// The originating file's interning id — for in-result correlation only.
79    pub file: FileId,
80    /// The source span this diagnostic points at, as byte offsets.
81    pub range: rowan::TextRange,
82    /// Human-readable message describing the problem.
83    pub message: String,
84    /// Structured error code for documentation and tooling.
85    pub code: DiagnosticCode,
86    /// The severity this diagnostic was actually resolved at
87    /// (`brink_analyzer::effective_severity`, not the raw
88    /// [`DiagnosticCode::severity`] default) — a `[lints]` re-leveled code
89    /// (including a down-level to `Info`/`Hint`, issue #1162) carries its
90    /// overridden severity here, so a renderer never has to re-derive it
91    /// (and never has to assume every `CompileOutput::warnings` entry is
92    /// actually `Severity::Warning`).
93    pub severity: Severity,
94}
95
96/// Successful compilation output, including any non-fatal warnings.
97#[derive(Debug)]
98pub struct CompileOutput {
99    pub data: StoryData,
100    pub warnings: Vec<ResolvedDiagnostic>,
101}
102
103/// Compile an ink story from an entry-point file path.
104///
105/// Reads files from disk, follows INCLUDEs, and runs the full compilation
106/// pipeline. Returns the compiled story data or a list of diagnostics.
107///
108/// **Test/bench/example use only** — gated behind the `test-util` feature;
109/// see the module docs. Bypasses `Environment` entirely, so a real consumer
110/// should use `brink_environment::compile(&Environment)` instead.
111#[cfg(feature = "test-util")]
112pub fn compile_path(path: &Path) -> Result<CompileOutput, CompileError> {
113    compile(path.to_string_lossy().as_ref(), |p| {
114        std::fs::read_to_string(p).map_err(|e| io::Error::new(e.kind(), format!("{p}: {e}")))
115    })
116}
117
118/// Compile an ink story from an entry-point file path with explicit analysis
119/// options — e.g. the T1b `--dialect` flag (`AnalysisOptions::dialect`).
120///
121/// **Test/bench/example use only** — gated behind the `test-util` feature;
122/// see the module docs. Bypasses `Environment` entirely, so a real consumer
123/// should use `brink_environment::compile(&Environment)` instead.
124#[cfg(feature = "test-util")]
125pub fn compile_path_with_options(
126    path: &Path,
127    options: AnalysisOptions,
128) -> Result<CompileOutput, CompileError> {
129    compile_with_options(
130        path.to_string_lossy().as_ref(),
131        |p| std::fs::read_to_string(p).map_err(|e| io::Error::new(e.kind(), format!("{p}: {e}"))),
132        options,
133    )
134}
135
136/// Compile an ink story with caller-provided file reading.
137///
138/// The `read_file` callback is called for the entry point and each
139/// `INCLUDE`d file discovered during parsing. This enables compilation in
140/// WASM, tests, and editor contexts where files are not on disk.
141///
142/// **Test/bench/example use only** — gated behind the `test-util` feature;
143/// see the module docs. Bypasses `Environment` entirely, so a real consumer
144/// should use `brink_environment::compile(&Environment)` instead.
145#[cfg(feature = "test-util")]
146pub fn compile<F>(entry: &str, read_file: F) -> Result<CompileOutput, CompileError>
147where
148    F: FnMut(&str) -> Result<String, io::Error>,
149{
150    driver::compile_with_options(entry, read_file, AnalysisOptions::default())
151}
152
153/// Compile with explicit analysis options — e.g. a registered host-capability
154/// manifest and external-check severity (the "compiler flag, error by
155/// default"). Manifest-driven diagnostics are surfaced as compile warnings or
156/// errors per the severity policy.
157///
158/// **Test/bench/example use only** — gated behind the `test-util` feature;
159/// see the module docs. Bypasses `Environment` entirely, so a real consumer
160/// should use `brink_environment::compile(&Environment)` instead.
161#[cfg(feature = "test-util")]
162pub fn compile_with_options<F>(
163    entry: &str,
164    read_file: F,
165    options: AnalysisOptions,
166) -> Result<CompileOutput, CompileError>
167where
168    F: FnMut(&str) -> Result<String, io::Error>,
169{
170    driver::compile_with_options(entry, read_file, options)
171}
172
173/// Errors that can occur during compilation.
174#[derive(Debug, thiserror::Error)]
175pub enum CompileError {
176    /// File I/O error (missing file, permission denied, etc.).
177    #[error("I/O error: {0}")]
178    Io(#[from] io::Error),
179    /// One or more diagnostics prevented compilation.
180    #[error("{} diagnostic(s) prevented compilation", .0.len())]
181    Diagnostics(Vec<ResolvedDiagnostic>),
182    /// Circular INCLUDE dependency detected.
183    #[error("circular INCLUDE dependency: {0}")]
184    CircularInclude(String),
185    /// Codegen (`brink-codegen-inkb`) refused a `Program` that violates an
186    /// invariant an earlier compiler stage is supposed to guarantee — a
187    /// compiler bug, not an authoring mistake. See
188    /// `brink_codegen_inkb::CodegenError` and #586.
189    #[error("internal codegen error: {0}")]
190    Codegen(#[from] brink_codegen_inkb::CodegenError),
191    /// Native (`.brink`) discovery produced a source key that is not
192    /// root-relative (contains a `..` segment) — see
193    /// `brink_driver::DiscoverError::InvalidKey` (issue #1288 review note
194    /// (a)). Not reachable through `RealFs`/`GitRev` today; a save-key-
195    /// identity guardrail against a future `SourceTree` impl that doesn't
196    /// uphold the contract.
197    #[error("invalid source key `{0}` (must be root-relative, no `..`)")]
198    InvalidSourceKey(String),
199    /// Native (`.brink`) discovery was handed a `SourceTree` that listed a
200    /// non-`.brink` key — see `brink_driver::DiscoverError::NonNativeKey`
201    /// (issue #1371). Not reachable through `prepare_driver`'s `RealFs::new`
202    /// today (native-scoped, `.brink`-only); a guardrail against a future
203    /// caller mistakenly widening the tree it hands to native discovery.
204    #[error("source key `{0}` is not a native `.brink` file")]
205    NonNativeSourceKey(String),
206}
207
208impl From<brink_driver::DiscoverError> for CompileError {
209    fn from(err: brink_driver::DiscoverError) -> Self {
210        match err {
211            brink_driver::DiscoverError::Io(e) => Self::Io(e),
212            brink_driver::DiscoverError::CircularInclude(msg) => Self::CircularInclude(msg),
213            brink_driver::DiscoverError::InvalidKey(key) => Self::InvalidSourceKey(key),
214            brink_driver::DiscoverError::NonNativeKey(key) => Self::NonNativeSourceKey(key),
215        }
216    }
217}