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
44pub fn legacy_ambient_capabilities_enabled() -> bool {
45    std::env::var(HARN_LEGACY_AMBIENT_CAPABILITIES_ENV).is_ok_and(|value| {
46        matches!(
47            value.trim().to_ascii_lowercase().as_str(),
48            "1" | "true" | "yes" | "on"
49        )
50    })
51}
52
53/// Whether an old hostlib wire name projects a method from the authoritative
54/// typed host-capability registry. This keeps compatibility recognition exact:
55/// arbitrary `hostlib_*` spellings remain undefined.
56pub fn is_registered_legacy_hostlib_name(name: &str) -> bool {
57    if name == "hostlib_enable" {
58        return true;
59    }
60    harn_builtin_meta::host_capabilities::capability_binding_for_legacy_hostlib_name(name).is_some()
61}
62
63/// Exact behavior-preserving spellings removed during the typed-Harness
64/// cutover. The compiler lowers these to the canonical manifest name only in
65/// compatibility mode; strict source continues to reject them.
66pub fn legacy_builtin_alias_target(name: &str) -> Option<&'static str> {
67    match name {
68        "regex_replace_all" => Some("regex_replace"),
69        "task_current" => Some("runtime_context"),
70        _ => None,
71    }
72}
73
74/// Host operations the embedding project declared, as `(capability, method)`.
75///
76/// A host registers these at runtime, so no static contract in this workspace
77/// owns them and the capability-method check would otherwise report every one
78/// as undeclared. `[check].host_capabilities` / `host_capabilities_path` is
79/// exactly that declaration, so the command that resolves it installs it here
80/// and the checker treats the pair as known.
81static DECLARED_HOST_OPERATIONS: std::sync::RwLock<
82    Option<std::collections::HashSet<(String, String)>>,
83> = std::sync::RwLock::new(None);
84
85/// Install the resolved host-capability declaration for this process.
86///
87/// Entries accumulate rather than replace: one invocation may check files that
88/// resolve different manifests, and dropping the earlier set would make the
89/// diagnostic depend on file ordering.
90pub fn install_declared_host_operations<I, C, M>(operations: I)
91where
92    I: IntoIterator<Item = (C, M)>,
93    C: Into<String>,
94    M: Into<String>,
95{
96    let Ok(mut guard) = DECLARED_HOST_OPERATIONS.write() else {
97        return;
98    };
99    let declared = guard.get_or_insert_with(std::collections::HashSet::new);
100    for (capability, method) in operations {
101        declared.insert((capability.into(), method.into()));
102    }
103}
104
105/// Did the embedding project declare `capability.method` as a host operation?
106pub fn is_declared_host_operation(capability: &str, method: &str) -> bool {
107    DECLARED_HOST_OPERATIONS
108        .read()
109        .ok()
110        .and_then(|guard| {
111            guard
112                .as_ref()
113                .map(|declared| declared.contains(&(capability.to_string(), method.to_string())))
114        })
115        .unwrap_or(false)
116}
117
118/// Returns `true` if `name` is a builtin recognized by the parser's static analyzer.
119pub fn is_known_builtin(name: &str) -> bool {
120    builtin_signatures::is_builtin(name)
121}
122
123/// Opt-in ambient bridge: treat a registered builtin as known without a typed
124/// `Harness` capability import.
125pub fn is_legacy_ambient_builtin(name: &str) -> bool {
126    legacy_ambient_capabilities_enabled() && is_known_builtin(name)
127}
128
129/// Every builtin name known to the parser, alphabetically. Enables bidirectional
130/// drift checks against the VM's runtime registry.
131pub fn known_builtin_names() -> impl Iterator<Item = &'static str> {
132    builtin_signatures::iter_builtin_names()
133}
134
135pub fn known_builtin_metadata() -> impl Iterator<Item = builtin_signatures::BuiltinMetadata> {
136    builtin_signatures::iter_builtin_metadata()
137}
138
139/// Names sourced only from the parser's hand-written static fallback tables
140/// (not the driver-installed `#[harn_builtin]` registry). Lets cross-crate
141/// drift guards assert the static tables don't overlap with macro-published
142/// or `runtime_only` builtins.
143pub fn static_signature_names() -> impl Iterator<Item = &'static str> {
144    builtin_signatures::static_signature_names()
145}
146
147/// Error from a source processing pipeline stage. Wraps the inner error
148/// types so callers can dispatch on the failing stage.
149#[derive(Debug)]
150pub enum PipelineError {
151    Lex(harn_lexer::LexerError),
152    Parse(ParserError),
153    /// Boxed to keep the enum small on the stack — TypeDiagnostic contains
154    /// a Vec<FixEdit>.
155    TypeCheck(Box<TypeDiagnostic>),
156}
157
158impl std::fmt::Display for PipelineError {
159    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
160        match self {
161            PipelineError::Lex(e) => e.fmt(f),
162            PipelineError::Parse(e) => e.fmt(f),
163            PipelineError::TypeCheck(diag) => write!(f, "type error: {}", diag.message),
164        }
165    }
166}
167
168impl std::error::Error for PipelineError {}
169
170impl From<harn_lexer::LexerError> for PipelineError {
171    fn from(e: harn_lexer::LexerError) -> Self {
172        PipelineError::Lex(e)
173    }
174}
175
176impl From<ParserError> for PipelineError {
177    fn from(e: ParserError) -> Self {
178        PipelineError::Parse(e)
179    }
180}
181
182impl PipelineError {
183    /// Extract the source span, if any, for diagnostic rendering.
184    pub fn span(&self) -> Option<&harn_lexer::Span> {
185        match self {
186            PipelineError::Lex(e) => match e {
187                harn_lexer::LexerError::UnexpectedCharacter(_, span)
188                | harn_lexer::LexerError::UnterminatedString(span)
189                | harn_lexer::LexerError::IntegerLiteralOutOfRange(_, span)
190                | harn_lexer::LexerError::UnterminatedBlockComment(span) => Some(span),
191            },
192            PipelineError::Parse(e) => match e {
193                ParserError::Unexpected { span, .. } => Some(span),
194                ParserError::UnexpectedEof { span, .. } => Some(span),
195            },
196            PipelineError::TypeCheck(diag) => diag.span.as_ref(),
197        }
198    }
199}
200
201/// Lex and parse source into an AST.
202pub fn parse_source(source: &str) -> Result<Vec<SNode>, PipelineError> {
203    let mut lexer = harn_lexer::Lexer::new(source);
204    let tokens = lexer.tokenize()?;
205    let mut parser = Parser::new(tokens);
206    Ok(parser.parse()?)
207}
208
209/// Lex, parse, and type-check source. Returns the AST and any type
210/// diagnostics (which may include warnings even on success).
211pub fn check_source(source: &str) -> Result<(Vec<SNode>, Vec<TypeDiagnostic>), PipelineError> {
212    let program = parse_source(source)?;
213    let diagnostics = TypeChecker::new().check_with_source(&program, source);
214    Ok((program, diagnostics))
215}
216
217/// Lex, parse, and type-check, bailing on the first type error.
218pub fn check_source_strict(source: &str) -> Result<Vec<SNode>, PipelineError> {
219    let (program, diagnostics) = check_source(source)?;
220    for diag in &diagnostics {
221        if diag.severity == DiagnosticSeverity::Error {
222            return Err(PipelineError::TypeCheck(Box::new(diag.clone())));
223        }
224    }
225    Ok(program)
226}
227
228#[cfg(test)]
229mod pipeline_tests {
230    use super::*;
231
232    #[test]
233    fn parse_source_valid() {
234        let program = parse_source("const x = 1").unwrap();
235        assert!(!program.is_empty());
236    }
237
238    #[test]
239    fn parse_source_lex_error() {
240        let err = parse_source("let x = `").unwrap_err();
241        assert!(matches!(err, PipelineError::Lex(_)));
242        assert!(err.span().is_some());
243        assert!(err.to_string().contains("Unexpected character"));
244    }
245
246    #[test]
247    fn parse_source_parse_error() {
248        let err = parse_source("let = 1").unwrap_err();
249        assert!(matches!(err, PipelineError::Parse(_)));
250        assert!(err.span().is_some());
251    }
252
253    #[test]
254    fn check_source_returns_diagnostics() {
255        let (program, _diagnostics) = check_source("const x = 1").unwrap();
256        assert!(!program.is_empty());
257    }
258
259    #[test]
260    fn check_source_strict_passes_valid_code() {
261        let program = check_source_strict("const x = 1\nlog(x)").unwrap();
262        assert!(!program.is_empty());
263    }
264
265    #[test]
266    fn check_source_strict_catches_lex_error() {
267        let err = check_source_strict("`").unwrap_err();
268        assert!(matches!(err, PipelineError::Lex(_)));
269    }
270
271    #[test]
272    fn pipeline_error_display_is_informative() {
273        let err = parse_source("`").unwrap_err();
274        let msg = err.to_string();
275        assert!(!msg.is_empty());
276        assert!(msg.contains('`') || msg.contains("Unexpected"));
277    }
278
279    #[test]
280    fn pipeline_error_size_is_bounded() {
281        // TypeCheck is boxed; guard against accidental growth of the other variants.
282        assert!(
283            std::mem::size_of::<PipelineError>() <= 96,
284            "PipelineError grew to {} bytes — consider boxing large variants",
285            std::mem::size_of::<PipelineError>()
286        );
287    }
288
289    #[test]
290    fn legacy_hostlib_names_must_resolve_through_the_typed_registry() {
291        assert!(is_registered_legacy_hostlib_name(
292            "hostlib_terminal_session_capture"
293        ));
294        assert!(is_registered_legacy_hostlib_name(
295            "hostlib_code_index_agent_heartbeat"
296        ));
297        assert!(is_registered_legacy_hostlib_name("hostlib_enable"));
298        assert!(!is_registered_legacy_hostlib_name(
299            "hostlib_terminal_session_not_registered"
300        ));
301        assert!(!is_registered_legacy_hostlib_name("hostlib_unknown_ping"));
302    }
303}