Skip to main content

harn_parser/
lib.rs

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