Skip to main content

harn_parser/
lib.rs

1pub mod acp_ambient_globals;
2pub mod analysis;
3mod ast;
4pub mod ast_json;
5pub mod builtin_methods;
6pub mod builtin_signatures;
7pub mod const_eval;
8pub mod diagnostic;
9pub mod diagnostic_codes;
10pub mod interpolation;
11pub mod lexical;
12mod namespace_demand;
13pub mod namespace_signatures;
14pub mod operator_migration;
15pub mod operators;
16pub mod param_annotations;
17mod parser;
18pub mod runtime_stack;
19pub mod stdlib_metadata;
20pub mod typechecker;
21pub mod visit;
22
23pub use ast::*;
24pub use diagnostic_codes::{
25    Category as DiagnosticCodeCategory, Code as DiagnosticCode, ParseRepairSafetyError, Repair,
26    RepairId, RepairSafety, RepairTemplate, REPAIR_REGISTRY,
27};
28pub use namespace_demand::{namespace_import_demands, NamespaceDemand};
29pub use namespace_signatures::NamespaceMemberSignature;
30pub use parser::*;
31pub use runtime_stack::{PARSE_STACK_SIZE, RUNTIME_STACK_SIZE};
32pub use stdlib_metadata::{
33    parse_for_span as parse_stdlib_metadata, synthesize_example, StdlibMetadata,
34};
35pub use typechecker::{
36    block_definitely_exits, canonical_predicate_type, format_type, stmt_definitely_exits,
37    substitute_type_expr, BindingTypeInfo, DiagnosticDetails, DiagnosticSeverity, InlayHintInfo,
38    NamespaceImportBinding, PredicateModelRoute, PredicateQuestionKind, PredicateQuestionSpec,
39    PredicateSite, PredicateSiteKind, TypeCheckFacts, TypeChecker, TypeDiagnostic,
40};
41
42pub use builtin_signatures::install_builtin_manifest;
43
44/// Explicit process-level bridge for downstream packages that have not yet
45/// completed the typed `Harness` capability migration.
46///
47/// The bridge is intentionally opt-in and keeps the strict source surface as
48/// the default. Defining the variable is not enough: it must be set to one of
49/// `1`, `true`, `yes`, or `on`, so an empty or `0` value leaves strict
50/// enforcement in place.
51pub const HARN_LEGACY_AMBIENT_CAPABILITIES_ENV: &str = "HARN_LEGACY_AMBIENT_CAPABILITIES";
52
53/// Cached parse of [`HARN_LEGACY_AMBIENT_CAPABILITIES_ENV`].
54///
55/// `0` = not read yet, `1` = disabled, `2` = enabled. The bridge flag is
56/// consulted on every builtin-name lookup the type checker performs, and
57/// `getenv` rescans the whole process environment per call — measurably hot
58/// on whole-file typechecks. A process cannot have its environment changed
59/// from outside once started, so one read is authoritative; the only writers
60/// are tests, which go through [`refresh_legacy_ambient_capabilities`].
61static LEGACY_AMBIENT_CAPABILITIES: std::sync::atomic::AtomicU8 =
62    std::sync::atomic::AtomicU8::new(0);
63
64#[cfg(test)]
65pub(crate) fn legacy_ambient_capabilities_test_lock() -> &'static std::sync::Mutex<()> {
66    static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
67    LOCK.get_or_init(|| std::sync::Mutex::new(()))
68}
69
70pub fn legacy_ambient_capabilities_enabled() -> bool {
71    match LEGACY_AMBIENT_CAPABILITIES.load(std::sync::atomic::Ordering::Relaxed) {
72        1 => false,
73        2 => true,
74        _ => refresh_legacy_ambient_capabilities(),
75    }
76}
77
78/// Re-read the legacy-bridge flag from the process environment and update the
79/// cached value, returning the fresh result.
80///
81/// Only needed by code that mutates `HARN_LEGACY_AMBIENT_CAPABILITIES` inside
82/// the running process — in practice, tests. Call it after every
83/// `set_var`/`remove_var` of the flag so later reads observe the change.
84pub fn refresh_legacy_ambient_capabilities() -> bool {
85    let enabled = std::env::var_os(HARN_LEGACY_AMBIENT_CAPABILITIES_ENV).is_some_and(|value| {
86        value.to_str().is_some_and(|value| {
87            matches!(
88                value.trim().to_ascii_lowercase().as_str(),
89                "1" | "true" | "yes" | "on"
90            )
91        })
92    });
93    LEGACY_AMBIENT_CAPABILITIES.store(
94        if enabled { 2 } else { 1 },
95        std::sync::atomic::Ordering::Relaxed,
96    );
97    enabled
98}
99
100/// Whether an old hostlib wire name projects a method from the authoritative
101/// typed host-capability registry. This keeps compatibility recognition exact:
102/// arbitrary `hostlib_*` spellings remain undefined.
103pub fn is_registered_legacy_hostlib_name(name: &str) -> bool {
104    if name == "hostlib_enable" {
105        return true;
106    }
107    harn_builtin_meta::host_capabilities::capability_binding_for_legacy_hostlib_name(name).is_some()
108}
109
110/// Exact behavior-preserving spellings removed during the typed-Harness
111/// cutover. The compiler lowers these to the canonical manifest name only in
112/// compatibility mode; strict source continues to reject them.
113pub fn legacy_builtin_alias_target(name: &str) -> Option<&'static str> {
114    match name {
115        "regex_replace_all" => Some("regex_replace"),
116        "task_current" => Some("runtime_context"),
117        _ => None,
118    }
119}
120
121/// Host operations the embedding project declared, as `(capability, method)`.
122///
123/// A host registers these at runtime, so no static contract in this workspace
124/// owns them and the capability-method check would otherwise report every one
125/// as undeclared. `[check].host_capabilities` / `host_capabilities_path` is
126/// exactly that declaration, so the command that resolves it installs it here
127/// and the checker treats the pair as known.
128static DECLARED_HOST_OPERATIONS: std::sync::RwLock<
129    Option<std::collections::HashSet<(String, String)>>,
130> = std::sync::RwLock::new(None);
131
132/// Install the resolved host-capability declaration for this process.
133///
134/// Entries accumulate rather than replace: one invocation may check files that
135/// resolve different manifests, and dropping the earlier set would make the
136/// diagnostic depend on file ordering.
137pub fn install_declared_host_operations<I, C, M>(operations: I)
138where
139    I: IntoIterator<Item = (C, M)>,
140    C: Into<String>,
141    M: Into<String>,
142{
143    let Ok(mut guard) = DECLARED_HOST_OPERATIONS.write() else {
144        return;
145    };
146    let declared = guard.get_or_insert_with(std::collections::HashSet::new);
147    for (capability, method) in operations {
148        declared.insert((capability.into(), method.into()));
149    }
150}
151
152/// Did the embedding project declare `capability.method` as a host operation?
153pub fn is_declared_host_operation(capability: &str, method: &str) -> bool {
154    DECLARED_HOST_OPERATIONS
155        .read()
156        .ok()
157        .and_then(|guard| {
158            guard
159                .as_ref()
160                .map(|declared| declared.contains(&(capability.to_string(), method.to_string())))
161        })
162        .unwrap_or(false)
163}
164
165/// Returns `true` if `name` is a builtin recognized by the parser's static analyzer.
166pub fn is_known_builtin(name: &str) -> bool {
167    builtin_signatures::is_builtin(name)
168}
169
170/// Opt-in ambient bridge: treat a registered builtin as known without a typed
171/// `Harness` capability import.
172pub fn is_legacy_ambient_builtin(name: &str) -> bool {
173    legacy_ambient_capabilities_enabled() && is_known_builtin(name)
174}
175
176/// Every builtin name known to the parser, alphabetically. Enables bidirectional
177/// drift checks against the VM's runtime registry.
178pub fn known_builtin_names() -> impl Iterator<Item = &'static str> {
179    builtin_signatures::iter_builtin_names()
180}
181
182pub fn known_builtin_metadata() -> impl Iterator<Item = builtin_signatures::BuiltinMetadata> {
183    builtin_signatures::iter_builtin_metadata()
184}
185
186/// Names sourced only from the parser's hand-written static fallback tables
187/// (not the driver-installed `#[harn_builtin]` registry). Lets cross-crate
188/// drift guards assert the static tables don't overlap with macro-published
189/// or `runtime_only` builtins.
190pub fn static_signature_names() -> impl Iterator<Item = &'static str> {
191    builtin_signatures::static_signature_names()
192}
193
194/// Error from a source processing pipeline stage. Wraps the inner error
195/// types so callers can dispatch on the failing stage.
196#[derive(Debug)]
197#[non_exhaustive]
198pub enum PipelineError {
199    Lex(harn_lexer::LexerError),
200    Parse(ParserError),
201    /// Boxed to keep the enum small on the stack — TypeDiagnostic contains
202    /// a Vec<FixEdit>.
203    TypeCheck(Box<TypeDiagnostic>),
204}
205
206impl std::fmt::Display for PipelineError {
207    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
208        match self {
209            PipelineError::Lex(e) => e.fmt(f),
210            PipelineError::Parse(e) => e.fmt(f),
211            PipelineError::TypeCheck(diag) => write!(f, "type error: {}", diag.message),
212        }
213    }
214}
215
216impl std::error::Error for PipelineError {}
217
218impl From<harn_lexer::LexerError> for PipelineError {
219    fn from(e: harn_lexer::LexerError) -> Self {
220        PipelineError::Lex(e)
221    }
222}
223
224impl From<ParserError> for PipelineError {
225    fn from(e: ParserError) -> Self {
226        PipelineError::Parse(e)
227    }
228}
229
230impl PipelineError {
231    /// Extract the source span, if any, for diagnostic rendering.
232    pub fn span(&self) -> Option<&harn_lexer::Span> {
233        match self {
234            PipelineError::Lex(e) => match e {
235                harn_lexer::LexerError::UnexpectedCharacter(_, span)
236                | harn_lexer::LexerError::UnterminatedString(span)
237                | harn_lexer::LexerError::IntegerLiteralOutOfRange(_, span)
238                | harn_lexer::LexerError::UnterminatedBlockComment(span) => Some(span),
239            },
240            PipelineError::Parse(e) => match e {
241                ParserError::Unexpected { span, .. } => Some(span),
242                ParserError::UnexpectedEof { span, .. } => Some(span),
243                ParserError::RemovedRangeSyntax { span } => Some(span),
244            },
245            PipelineError::TypeCheck(diag) => diag.span.as_ref(),
246        }
247    }
248}
249
250/// Lex and parse source into an AST.
251pub fn parse_source(source: &str) -> Result<Vec<SNode>, PipelineError> {
252    let mut lexer = harn_lexer::Lexer::new(source);
253    let tokens = lexer.tokenize()?;
254    let mut parser = Parser::new(tokens);
255    Ok(parser.parse()?)
256}
257
258/// Lex, parse, and type-check source. Returns the AST and any type
259/// diagnostics (which may include warnings even on success).
260pub fn check_source(source: &str) -> Result<(Vec<SNode>, Vec<TypeDiagnostic>), PipelineError> {
261    let program = parse_source(source)?;
262    let diagnostics = TypeChecker::new().check_with_source(&program, source);
263    Ok((program, diagnostics))
264}
265
266/// Lex, parse, and type-check, bailing on the first type error.
267pub fn check_source_strict(source: &str) -> Result<Vec<SNode>, PipelineError> {
268    let (program, diagnostics) = check_source(source)?;
269    for diag in &diagnostics {
270        if diag.severity == DiagnosticSeverity::Error {
271            return Err(PipelineError::TypeCheck(Box::new(diag.clone())));
272        }
273    }
274    Ok(program)
275}
276
277#[cfg(test)]
278mod pipeline_tests {
279    use super::*;
280
281    #[test]
282    fn parse_source_lex_error() {
283        let err = parse_source("let x = `").unwrap_err();
284        assert!(matches!(err, PipelineError::Lex(_)));
285        assert!(err.span().is_some());
286        assert!(err.to_string().contains("Unexpected character"));
287    }
288
289    #[test]
290    fn parse_source_parse_error() {
291        let err = parse_source("let = 1").unwrap_err();
292        assert!(matches!(err, PipelineError::Parse(_)));
293        assert!(err.span().is_some());
294    }
295
296    #[test]
297    fn check_source_returns_diagnostics() {
298        let (program, _diagnostics) = check_source("const x = 1").unwrap();
299        assert!(!program.is_empty());
300    }
301
302    #[test]
303    fn check_source_strict_passes_valid_code() {
304        let program = check_source_strict("const x = 1\nlog(x)").unwrap();
305        assert!(!program.is_empty());
306    }
307
308    #[test]
309    fn check_source_strict_catches_lex_error() {
310        let err = check_source_strict("`").unwrap_err();
311        assert!(matches!(err, PipelineError::Lex(_)));
312    }
313
314    #[test]
315    fn pipeline_error_display_is_informative() {
316        let err = parse_source("`").unwrap_err();
317        let msg = err.to_string();
318        assert!(!msg.is_empty());
319        assert!(msg.contains('`') || msg.contains("Unexpected"));
320    }
321
322    #[test]
323    fn pipeline_error_size_is_bounded() {
324        // TypeCheck is boxed; guard against accidental growth of the other variants.
325        assert!(
326            std::mem::size_of::<PipelineError>() <= 96,
327            "PipelineError grew to {} bytes — consider boxing large variants",
328            std::mem::size_of::<PipelineError>()
329        );
330    }
331
332    #[test]
333    fn legacy_hostlib_names_must_resolve_through_the_typed_registry() {
334        assert!(is_registered_legacy_hostlib_name(
335            "hostlib_terminal_session_capture"
336        ));
337        assert!(is_registered_legacy_hostlib_name(
338            "hostlib_code_index_agent_heartbeat"
339        ));
340        assert!(is_registered_legacy_hostlib_name("hostlib_enable"));
341        assert!(!is_registered_legacy_hostlib_name(
342            "hostlib_terminal_session_not_registered"
343        ));
344        assert!(!is_registered_legacy_hostlib_name("hostlib_unknown_ping"));
345    }
346}