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