Skip to main content

harn_parser/typechecker/
mod.rs

1use std::collections::HashSet;
2
3use crate::ast::*;
4use crate::builtin_signatures;
5use harn_lexer::{FixEdit, Span};
6
7mod binary_ops;
8mod exits;
9mod format;
10mod inference;
11mod schema_inference;
12mod scope;
13mod union;
14
15pub use exits::{block_definitely_exits, stmt_definitely_exits};
16pub use format::{format_type, shape_mismatch_detail};
17
18use schema_inference::schema_type_expr_from_node;
19use scope::TypeScope;
20
21/// An inlay hint produced during type checking.
22#[derive(Debug, Clone)]
23pub struct InlayHintInfo {
24    /// Position (line, column) where the hint should be displayed (after the variable name).
25    pub line: usize,
26    pub column: usize,
27    /// The type label to display (e.g. ": string").
28    pub label: String,
29}
30
31/// A diagnostic produced by the type checker.
32#[derive(Debug, Clone)]
33pub struct TypeDiagnostic {
34    pub message: String,
35    pub severity: DiagnosticSeverity,
36    pub span: Option<Span>,
37    pub help: Option<String>,
38    pub related: Vec<RelatedDiagnostic>,
39    /// Machine-applicable fix edits.
40    pub fix: Option<Vec<FixEdit>>,
41    /// Optional structured payload that higher-level tooling (e.g. the
42    /// LSP code-action provider) can consume to synthesise fixes that
43    /// need more than a static `FixEdit`. Out-of-band from `fix` so the
44    /// string-based rendering pipeline doesn't have to care.
45    pub details: Option<DiagnosticDetails>,
46}
47
48#[derive(Debug, Clone)]
49pub struct RelatedDiagnostic {
50    pub span: Span,
51    pub message: String,
52}
53
54/// Optional structured companion data on a `TypeDiagnostic`. The
55/// variants map one-to-one with diagnostics that have specific
56/// tooling-consumable state beyond the human-readable message; each
57/// variant is attached only by the sites that produce its
58/// corresponding diagnostic, so a consumer can pattern-match on the
59/// variant without parsing the error string.
60#[derive(Debug, Clone)]
61pub enum DiagnosticDetails {
62    /// A concrete expected/found mismatch. Renderers can use this to
63    /// provide stable labels without scraping human-readable text.
64    TypeMismatch,
65    /// A `match` expression with missing variant coverage. `missing`
66    /// holds the formatted literal values of each uncovered variant
67    /// (quoted for strings, bare for ints), ready to drop into a new
68    /// arm prefix. The diagnostic's `span` covers the whole `match`
69    /// expression, so a code-action can locate the closing `}` by
70    /// reading the source at `span.end`.
71    NonExhaustiveMatch { missing: Vec<String> },
72    /// A type-aware lint diagnostic. These diagnostics are produced by
73    /// the type checker because the rule depends on flow-sensitive type
74    /// information, but `harn lint` should surface and filter them like
75    /// ordinary lint rules.
76    LintRule { rule: &'static str },
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub enum DiagnosticSeverity {
81    Error,
82    Warning,
83}
84
85/// The static type checker.
86pub struct TypeChecker {
87    diagnostics: Vec<TypeDiagnostic>,
88    scope: TypeScope,
89    source: Option<String>,
90    hints: Vec<InlayHintInfo>,
91    /// When true, flag unvalidated boundary-API values used in field access.
92    strict_types: bool,
93    /// Lexical depth of enclosing function-like bodies (fn/tool/pipeline/closure).
94    /// `try*` requires `fn_depth > 0` so the rethrow has a body to live in.
95    fn_depth: usize,
96    /// Lexical depth of enclosing `gen fn` bodies. `emit` is only valid here.
97    stream_fn_depth: usize,
98    /// Expected emitted value type for each enclosing `gen fn`.
99    stream_emit_types: Vec<Option<TypeExpr>>,
100    /// Maps function name -> deprecation metadata `(since, use_hint)`. Populated
101    /// when an `@deprecated` attribute is encountered on a top-level fn decl
102    /// during the `check_inner` pre-pass; consulted at every `FunctionCall`
103    /// site to emit a warning + help line.
104    deprecated_fns: std::collections::HashMap<String, (Option<String>, Option<String>)>,
105    /// Names statically known to be introduced by cross-module imports
106    /// (resolved via `harn-modules`). `Some(set)` switches the checker into
107    /// strict cross-module mode: an unresolved callable name is reported as
108    /// an error instead of silently passing through. `None` preserves the
109    /// conservative pre-v0.7.12 behavior (no cross-module undefined-name
110    /// diagnostics).
111    imported_names: Option<HashSet<String>>,
112    /// Type-like declarations imported from other modules. These are registered
113    /// into the scope before local checking so imported type aliases and tagged
114    /// unions participate in normal field access and narrowing.
115    imported_type_decls: Vec<SNode>,
116    /// Callable declarations imported from other modules. Only their
117    /// signatures are registered; bodies stay owned by the defining module.
118    imported_callable_decls: Vec<SNode>,
119}
120
121impl TypeChecker {
122    pub(in crate::typechecker) fn wildcard_type() -> TypeExpr {
123        TypeExpr::Named("_".into())
124    }
125
126    pub(in crate::typechecker) fn is_wildcard_type(ty: &TypeExpr) -> bool {
127        matches!(ty, TypeExpr::Named(name) if name == "_")
128    }
129
130    pub(in crate::typechecker) fn base_type_name(ty: &TypeExpr) -> Option<&str> {
131        match ty {
132            TypeExpr::Named(name) => Some(name.as_str()),
133            TypeExpr::Applied { name, .. } => Some(name.as_str()),
134            _ => None,
135        }
136    }
137
138    pub fn new() -> Self {
139        Self {
140            diagnostics: Vec::new(),
141            scope: TypeScope::new(),
142            source: None,
143            hints: Vec::new(),
144            strict_types: false,
145            fn_depth: 0,
146            stream_fn_depth: 0,
147            stream_emit_types: Vec::new(),
148            deprecated_fns: std::collections::HashMap::new(),
149            imported_names: None,
150            imported_type_decls: Vec::new(),
151            imported_callable_decls: Vec::new(),
152        }
153    }
154
155    /// Create a type checker with strict types mode.
156    /// When enabled, flags unvalidated boundary-API values used in field access.
157    pub fn with_strict_types(strict: bool) -> Self {
158        Self {
159            diagnostics: Vec::new(),
160            scope: TypeScope::new(),
161            source: None,
162            hints: Vec::new(),
163            strict_types: strict,
164            fn_depth: 0,
165            stream_fn_depth: 0,
166            stream_emit_types: Vec::new(),
167            deprecated_fns: std::collections::HashMap::new(),
168            imported_names: None,
169            imported_type_decls: Vec::new(),
170            imported_callable_decls: Vec::new(),
171        }
172    }
173
174    /// Attach the set of names statically introduced by cross-module imports.
175    ///
176    /// Enables strict cross-module undefined-call errors: call sites that are
177    /// not builtins, not local declarations, not struct constructors, not
178    /// callable variables, and not in `imported` will produce a type error.
179    ///
180    /// Passing `None` (the default) preserves pre-v0.7.12 behavior where
181    /// unresolved call names only surface via lint diagnostics. Callers
182    /// should only pass `Some(set)` when every import in the file resolved
183    /// — see `harn_modules::ModuleGraph::imported_names_for_file`.
184    pub fn with_imported_names(mut self, imported: HashSet<String>) -> Self {
185        self.imported_names = Some(imported);
186        self
187    }
188
189    /// Attach imported type / struct / enum / interface declarations. The
190    /// caller is responsible for resolving module imports and filtering the
191    /// visible declarations before passing them in.
192    pub fn with_imported_type_decls(mut self, imported: Vec<SNode>) -> Self {
193        self.imported_type_decls = imported;
194        self
195    }
196
197    /// Attach imported function / pipeline / tool declarations. The checker
198    /// registers only call signatures so imported pure-Harn functions enforce
199    /// their parameter annotations at the caller without checking the imported
200    /// body in the caller's scope.
201    pub fn with_imported_callable_decls(mut self, imported: Vec<SNode>) -> Self {
202        self.imported_callable_decls = imported;
203        self
204    }
205
206    /// Check a program with source text for autofix generation.
207    pub fn check_with_source(mut self, program: &[SNode], source: &str) -> Vec<TypeDiagnostic> {
208        self.source = Some(source.to_string());
209        self.check_inner(program).0
210    }
211
212    /// Check a program with strict types mode and source text.
213    pub fn check_strict_with_source(
214        mut self,
215        program: &[SNode],
216        source: &str,
217    ) -> Vec<TypeDiagnostic> {
218        self.source = Some(source.to_string());
219        self.check_inner(program).0
220    }
221
222    /// Check a program and return diagnostics.
223    pub fn check(self, program: &[SNode]) -> Vec<TypeDiagnostic> {
224        self.check_inner(program).0
225    }
226
227    /// Check whether a function call value is a boundary source that produces
228    /// unvalidated data.  Returns `None` if the value is type-safe
229    /// (e.g. llm_call with a schema option, or a non-boundary function).
230    pub(in crate::typechecker) fn detect_boundary_source(
231        value: &SNode,
232        scope: &TypeScope,
233    ) -> Option<String> {
234        match &value.node {
235            Node::FunctionCall { name, args, .. } => {
236                if !builtin_signatures::is_untyped_boundary_source(name) {
237                    return None;
238                }
239                // llm_call/llm_completion with a schema option are type-safe
240                if (name == "llm_call" || name == "llm_completion")
241                    && Self::llm_call_has_typed_schema_option(args, scope)
242                {
243                    return None;
244                }
245                Some(name.clone())
246            }
247            Node::Identifier(name) => scope.is_untyped_source(name).map(|s| s.to_string()),
248            _ => None,
249        }
250    }
251
252    /// True if an `llm_call` / `llm_completion` options dict names a
253    /// resolvable output schema. Used by the strict-types boundary checks
254    /// to suppress "unvalidated" warnings when the call site is typed.
255    /// Actual return-type narrowing is driven by the generic-builtin
256    /// dispatch path in `infer_type`, not this helper.
257    pub(in crate::typechecker) fn llm_call_has_typed_schema_option(
258        args: &[SNode],
259        scope: &TypeScope,
260    ) -> bool {
261        let Some(opts) = args.get(2) else {
262            return false;
263        };
264        let Node::DictLiteral(entries) = &opts.node else {
265            return false;
266        };
267        entries.iter().any(|entry| {
268            let key = match &entry.key.node {
269                Node::StringLiteral(k) | Node::Identifier(k) => k.as_str(),
270                _ => return false,
271            };
272            (key == "schema" || key == "output_schema")
273                && schema_type_expr_from_node(&entry.value, scope).is_some()
274        })
275    }
276
277    /// Check whether a type annotation is a concrete shape/struct type
278    /// (as opposed to bare `dict` or no annotation).
279    pub(in crate::typechecker) fn is_concrete_type(ty: &TypeExpr) -> bool {
280        matches!(
281            ty,
282            TypeExpr::Shape(_)
283                | TypeExpr::Applied { .. }
284                | TypeExpr::FnType { .. }
285                | TypeExpr::List(_)
286                | TypeExpr::Iter(_)
287                | TypeExpr::Generator(_)
288                | TypeExpr::Stream(_)
289                | TypeExpr::DictType(_, _)
290        ) || matches!(ty, TypeExpr::Named(n) if n != "dict" && n != "any" && n != "_")
291    }
292
293    /// Check a program and return both diagnostics and inlay hints.
294    pub fn check_with_hints(
295        mut self,
296        program: &[SNode],
297        source: &str,
298    ) -> (Vec<TypeDiagnostic>, Vec<InlayHintInfo>) {
299        self.source = Some(source.to_string());
300        self.check_inner(program)
301    }
302
303    pub(in crate::typechecker) fn error_at(&mut self, message: String, span: Span) {
304        self.diagnostics.push(TypeDiagnostic {
305            message,
306            severity: DiagnosticSeverity::Error,
307            span: Some(span),
308            help: None,
309            related: Vec::new(),
310            fix: None,
311            details: None,
312        });
313    }
314
315    #[allow(dead_code)]
316    pub(in crate::typechecker) fn error_at_with_help(
317        &mut self,
318        message: String,
319        span: Span,
320        help: String,
321    ) {
322        self.diagnostics.push(TypeDiagnostic {
323            message,
324            severity: DiagnosticSeverity::Error,
325            span: Some(span),
326            help: Some(help),
327            related: Vec::new(),
328            fix: None,
329            details: None,
330        });
331    }
332
333    pub(in crate::typechecker) fn type_mismatch_at(
334        &mut self,
335        context: impl Into<String>,
336        expected: &TypeExpr,
337        actual: &TypeExpr,
338        span: Span,
339        expected_origin: Option<(Span, String)>,
340        value_span: Option<Span>,
341        scope: &TypeScope,
342    ) {
343        let nested_mismatch = first_nested_mismatch(expected, actual, scope);
344        let mut message = format!(
345            "{}: expected {}, found {}",
346            context.into(),
347            format_type(expected),
348            format_type(actual)
349        );
350        if let Some(detail) = shape_mismatch_detail(expected, actual)
351            .or_else(|| nested_mismatch.as_ref().map(|note| note.message.clone()))
352        {
353            message.push_str(&format!(" ({detail})"));
354        }
355
356        let mut related = Vec::new();
357        if let Some((span, message)) = expected_origin {
358            related.push(RelatedDiagnostic { span, message });
359        }
360        if let Some(note) = nested_mismatch {
361            related.push(RelatedDiagnostic {
362                span,
363                message: format!("nested mismatch: {}", note.message),
364            });
365        }
366
367        self.diagnostics.push(TypeDiagnostic {
368            message,
369            severity: DiagnosticSeverity::Error,
370            span: Some(span),
371            help: coercion_suggestion(expected, actual, value_span, self.source.as_deref()),
372            related,
373            fix: None,
374            details: Some(DiagnosticDetails::TypeMismatch),
375        });
376    }
377
378    pub(in crate::typechecker) fn error_at_with_fix(
379        &mut self,
380        message: String,
381        span: Span,
382        fix: Vec<FixEdit>,
383    ) {
384        self.diagnostics.push(TypeDiagnostic {
385            message,
386            severity: DiagnosticSeverity::Error,
387            span: Some(span),
388            help: None,
389            related: Vec::new(),
390            fix: Some(fix),
391            details: None,
392        });
393    }
394
395    /// Diagnostic site for non-exhaustive `match` arms. Match arms must be
396    /// exhaustive — a missing-variant `match` is a hard error. Authors who
397    /// genuinely want partial coverage opt out with a wildcard `_` arm.
398    /// Partial `if/elif/else` chains are intentionally allowed and are
399    /// instead handled by `check_unknown_exhaustiveness`, which stays a
400    /// warning so the `unreachable()` opt-in pattern continues to work.
401    pub(in crate::typechecker) fn exhaustiveness_error_at(&mut self, message: String, span: Span) {
402        self.diagnostics.push(TypeDiagnostic {
403            message,
404            severity: DiagnosticSeverity::Error,
405            span: Some(span),
406            help: None,
407            related: Vec::new(),
408            fix: None,
409            details: None,
410        });
411    }
412
413    /// Like `exhaustiveness_error_at` but additionally attaches the
414    /// missing-variant list as structured details. LSP code-actions
415    /// read this to synthesise an "Add missing match arms" quick-fix
416    /// without string-parsing the message.
417    pub(in crate::typechecker) fn exhaustiveness_error_with_missing(
418        &mut self,
419        message: String,
420        span: Span,
421        missing: Vec<String>,
422    ) {
423        self.diagnostics.push(TypeDiagnostic {
424            message,
425            severity: DiagnosticSeverity::Error,
426            span: Some(span),
427            help: None,
428            related: Vec::new(),
429            fix: None,
430            details: Some(DiagnosticDetails::NonExhaustiveMatch { missing }),
431        });
432    }
433
434    pub(in crate::typechecker) fn warning_at(&mut self, message: String, span: Span) {
435        self.diagnostics.push(TypeDiagnostic {
436            message,
437            severity: DiagnosticSeverity::Warning,
438            span: Some(span),
439            help: None,
440            related: Vec::new(),
441            fix: None,
442            details: None,
443        });
444    }
445
446    #[allow(dead_code)]
447    pub(in crate::typechecker) fn warning_at_with_help(
448        &mut self,
449        message: String,
450        span: Span,
451        help: String,
452    ) {
453        self.diagnostics.push(TypeDiagnostic {
454            message,
455            severity: DiagnosticSeverity::Warning,
456            span: Some(span),
457            help: Some(help),
458            related: Vec::new(),
459            fix: None,
460            details: None,
461        });
462    }
463
464    pub(in crate::typechecker) fn lint_warning_at_with_fix(
465        &mut self,
466        rule: &'static str,
467        message: String,
468        span: Span,
469        help: String,
470        fix: Vec<FixEdit>,
471    ) {
472        self.diagnostics.push(TypeDiagnostic {
473            message,
474            severity: DiagnosticSeverity::Warning,
475            span: Some(span),
476            help: Some(help),
477            related: Vec::new(),
478            fix: Some(fix),
479            details: Some(DiagnosticDetails::LintRule { rule }),
480        });
481    }
482}
483
484#[derive(Debug)]
485struct MismatchNote {
486    message: String,
487}
488
489fn first_nested_mismatch(
490    expected: &TypeExpr,
491    actual: &TypeExpr,
492    scope: &TypeScope,
493) -> Option<MismatchNote> {
494    let expected = resolve_type_for_diagnostic(expected, scope);
495    let actual = resolve_type_for_diagnostic(actual, scope);
496    match (&expected, &actual) {
497        (TypeExpr::Shape(expected_fields), TypeExpr::Shape(actual_fields)) => {
498            for expected_field in expected_fields {
499                if expected_field.optional {
500                    continue;
501                }
502                let Some(actual_field) = actual_fields
503                    .iter()
504                    .find(|actual_field| actual_field.name == expected_field.name)
505                else {
506                    return Some(MismatchNote {
507                        message: format!(
508                            "field `{}` is missing; expected {}",
509                            expected_field.name,
510                            format_type(&expected_field.type_expr)
511                        ),
512                    });
513                };
514                if !types_compatible_for_diagnostic(
515                    &expected_field.type_expr,
516                    &actual_field.type_expr,
517                    scope,
518                ) {
519                    return Some(MismatchNote {
520                        message: format!(
521                            "field `{}` expected {}, found {}",
522                            expected_field.name,
523                            format_type(&expected_field.type_expr),
524                            format_type(&actual_field.type_expr)
525                        ),
526                    });
527                }
528            }
529            None
530        }
531        (TypeExpr::List(expected_inner), TypeExpr::List(actual_inner)) => {
532            if !types_compatible_for_diagnostic(expected_inner, actual_inner, scope)
533                || !types_compatible_for_diagnostic(actual_inner, expected_inner, scope)
534            {
535                Some(MismatchNote {
536                    message: format!(
537                        "list element expected {}, found {}",
538                        format_type(expected_inner),
539                        format_type(actual_inner)
540                    ),
541                })
542            } else {
543                None
544            }
545        }
546        (
547            TypeExpr::DictType(expected_key, expected_value),
548            TypeExpr::DictType(actual_key, actual_value),
549        ) => {
550            if !types_compatible_for_diagnostic(expected_key, actual_key, scope)
551                || !types_compatible_for_diagnostic(actual_key, expected_key, scope)
552            {
553                Some(MismatchNote {
554                    message: format!(
555                        "dict key expected {}, found {}",
556                        format_type(expected_key),
557                        format_type(actual_key)
558                    ),
559                })
560            } else if !types_compatible_for_diagnostic(expected_value, actual_value, scope)
561                || !types_compatible_for_diagnostic(actual_value, expected_value, scope)
562            {
563                Some(MismatchNote {
564                    message: format!(
565                        "dict value expected {}, found {}",
566                        format_type(expected_value),
567                        format_type(actual_value)
568                    ),
569                })
570            } else {
571                None
572            }
573        }
574        (
575            TypeExpr::Applied {
576                name: expected_name,
577                args: expected_args,
578            },
579            TypeExpr::Applied {
580                name: actual_name,
581                args: actual_args,
582            },
583        ) if expected_name == actual_name => expected_args
584            .iter()
585            .zip(actual_args.iter())
586            .enumerate()
587            .find_map(|(idx, (expected_arg, actual_arg))| {
588                if types_compatible_for_diagnostic(expected_arg, actual_arg, scope)
589                    && types_compatible_for_diagnostic(actual_arg, expected_arg, scope)
590                {
591                    None
592                } else {
593                    Some(MismatchNote {
594                        message: format!(
595                            "{} type argument {} expected {}, found {}",
596                            expected_name,
597                            idx + 1,
598                            format_type(expected_arg),
599                            format_type(actual_arg)
600                        ),
601                    })
602                }
603            }),
604        (
605            TypeExpr::FnType {
606                params: expected_params,
607                return_type: expected_return,
608            },
609            TypeExpr::FnType {
610                params: actual_params,
611                return_type: actual_return,
612            },
613        ) => {
614            for (idx, (expected_param, actual_param)) in
615                expected_params.iter().zip(actual_params.iter()).enumerate()
616            {
617                if !types_compatible_for_diagnostic(actual_param, expected_param, scope) {
618                    return Some(MismatchNote {
619                        message: format!(
620                            "function parameter {} expected {}, found {}",
621                            idx + 1,
622                            format_type(expected_param),
623                            format_type(actual_param)
624                        ),
625                    });
626                }
627            }
628            if !types_compatible_for_diagnostic(expected_return, actual_return, scope) {
629                Some(MismatchNote {
630                    message: format!(
631                        "function return expected {}, found {}",
632                        format_type(expected_return),
633                        format_type(actual_return)
634                    ),
635                })
636            } else {
637                None
638            }
639        }
640        _ => None,
641    }
642}
643
644fn types_compatible_for_diagnostic(
645    expected: &TypeExpr,
646    actual: &TypeExpr,
647    scope: &TypeScope,
648) -> bool {
649    TypeChecker::new().types_compatible(expected, actual, scope)
650}
651
652fn resolve_type_for_diagnostic(ty: &TypeExpr, scope: &TypeScope) -> TypeExpr {
653    TypeChecker::new().resolve_alias(ty, scope)
654}
655
656fn coercion_suggestion(
657    expected: &TypeExpr,
658    actual: &TypeExpr,
659    value_span: Option<Span>,
660    source: Option<&str>,
661) -> Option<String> {
662    let expr = value_span
663        .and_then(|span| source.and_then(|source| source.get(span.start..span.end)))
664        .map(str::trim)
665        .filter(|expr| !expr.is_empty());
666    if is_nilable(actual) {
667        return Some("handle `nil` first or provide a default with `??`".to_string());
668    }
669    let expected_ty = expected;
670    let expected = simple_type_name(expected)?;
671    let actual_name = simple_type_name(actual)?;
672    let with_expr = |template: &str| {
673        expr.map(|expr| template.replace("{}", expr))
674            .unwrap_or_else(|| template.replace("{}", "value"))
675    };
676
677    match (expected, actual_name) {
678        ("string", "int" | "float" | "bool" | "nil" | "duration") => {
679            Some(format!("did you mean `{}`?", with_expr("to_string({})")))
680        }
681        ("int", "string") => Some(format!("did you mean `{}`?", with_expr("to_int({})"))),
682        ("float", "string" | "int") => {
683            Some(format!("did you mean `{}`?", with_expr("to_float({})")))
684        }
685        (_, "nil") => Some("handle `nil` first or provide a default with `??`".to_string()),
686        _ if actual_is_result_of(expected_ty, actual) => Some(format!(
687            "did you mean `{}` or `{}`?",
688            with_expr("{}?"),
689            with_expr("unwrap_or({}, default)")
690        )),
691        _ => None,
692    }
693}
694
695fn simple_type_name(ty: &TypeExpr) -> Option<&str> {
696    match ty {
697        TypeExpr::Named(name) => Some(name.as_str()),
698        TypeExpr::LitString(_) => Some("string"),
699        TypeExpr::LitInt(_) => Some("int"),
700        _ => None,
701    }
702}
703
704fn is_nilable(ty: &TypeExpr) -> bool {
705    match ty {
706        TypeExpr::Union(members) if members.len() == 2 => members
707            .iter()
708            .any(|member| matches!(member, TypeExpr::Named(name) if name == "nil")),
709        _ => false,
710    }
711}
712
713fn actual_is_result_of(expected: &TypeExpr, actual: &TypeExpr) -> bool {
714    matches!(
715        actual,
716        TypeExpr::Applied { name, args }
717            if name == "Result" && args.first().is_some_and(|ok| ok == expected)
718    )
719}
720
721impl Default for TypeChecker {
722    fn default() -> Self {
723        Self::new()
724    }
725}
726
727#[cfg(test)]
728mod tests;