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