Skip to main content

harn_lint/
lib.rs

1//! Harn's lint crate. The public surface is intentionally narrow: a
2//! handful of `lint_*` entry points, the diagnostic and options types,
3//! and a couple of small utility functions reused by other crates. All
4//! walk state, rule dispatch, and source-aware rule implementations
5//! live in sibling modules.
6
7use std::collections::HashSet;
8use std::path::Path;
9
10use harn_modules::WildcardResolution;
11use harn_parser::{DiagnosticCode as Code, SNode};
12
13mod complexity;
14mod decls;
15mod diagnostic;
16mod engine_rule;
17mod fixes;
18mod harndoc;
19mod linter;
20mod naming;
21pub mod native;
22mod native_rule;
23mod rule;
24mod rules;
25
26#[cfg(test)]
27mod tests;
28
29pub use diagnostic::{LintDiagnostic, LintOptions, LintSeverity, DEFAULT_COMPLEXITY_THRESHOLD};
30pub use naming::simplify_bool_comparison;
31pub use rules::file_header::derive_file_header_title;
32pub use rules::template_variant_explosion::DEFAULT_BRANCH_THRESHOLD as DEFAULT_TEMPLATE_VARIANT_BRANCH_THRESHOLD;
33
34/// Lint a single `.harn.prompt` template source. Returns the
35/// diagnostics produced by the template-specific lint rules
36/// (`template-provider-identity-branch`, `template-variant-explosion`).
37///
38/// `branch_threshold` overrides the default for the variant-
39/// explosion rule (see [`DEFAULT_TEMPLATE_VARIANT_BRANCH_THRESHOLD`]);
40/// `disabled_rules` is the same comma-separated list `harn lint`
41/// accepts everywhere else.
42///
43/// Returns a single `LintDiagnostic` with rule `"template-parse"`
44/// when the template doesn't parse — surface that to the user before
45/// continuing, mirroring how `harn lint` reports parse failures for
46/// `.harn` programs.
47pub fn lint_prompt_template(
48    source: &str,
49    branch_threshold: Option<usize>,
50    disabled_rules: &[String],
51) -> Vec<LintDiagnostic> {
52    let constructs = match harn_vm::stdlib::template::lint::parse(source) {
53        Ok(constructs) => constructs,
54        Err(message) => {
55            return vec![LintDiagnostic {
56                code: Code::LintTemplateParse,
57                rule: "template-parse".into(),
58                message: format!("template did not parse: {message}"),
59                span: harn_lexer::Span::dummy(),
60                severity: LintSeverity::Error,
61                suggestion: None,
62                fix: None,
63            }];
64        }
65    };
66    let threshold = branch_threshold.unwrap_or(DEFAULT_TEMPLATE_VARIANT_BRANCH_THRESHOLD);
67    let mut diagnostics = Vec::new();
68    diagnostics.extend(rules::template_provider_identity::check(
69        &constructs,
70        source,
71    ));
72    diagnostics.extend(rules::template_variant_explosion::check(
73        &constructs,
74        threshold,
75        source,
76    ));
77    if disabled_rules.is_empty() {
78        diagnostics
79    } else {
80        diagnostics
81            .into_iter()
82            .filter(|d| !rule_disabled(&d.rule, disabled_rules))
83            .collect()
84    }
85}
86
87use linter::Linter;
88use rules::file_header::check_require_file_header;
89
90/// Lint an AST program and return all diagnostics.
91pub fn lint(program: &[SNode]) -> Vec<LintDiagnostic> {
92    lint_with_config_and_source(program, &[], None)
93}
94
95/// Lint an AST program with source-aware rules enabled.
96pub fn lint_with_source(program: &[SNode], source: &str) -> Vec<LintDiagnostic> {
97    lint_with_config_and_source(program, &[], Some(source))
98}
99
100/// Lint an AST program, filtering out diagnostics for disabled rules.
101pub fn lint_with_config(program: &[SNode], disabled_rules: &[String]) -> Vec<LintDiagnostic> {
102    lint_with_config_and_source(program, disabled_rules, None)
103}
104
105/// Lint an AST program, optionally using the original source for source-aware rules.
106pub fn lint_with_config_and_source(
107    program: &[SNode],
108    disabled_rules: &[String],
109    source: Option<&str>,
110) -> Vec<LintDiagnostic> {
111    lint_full(
112        program,
113        disabled_rules,
114        source,
115        &HashSet::new(),
116        &LintOptions::default(),
117        None,
118    )
119}
120
121/// Lint with cross-file import awareness. Functions named in
122/// `externally_imported_names` are exempt from the unused-function lint
123/// even without local references.
124pub fn lint_with_cross_file_imports(
125    program: &[SNode],
126    disabled_rules: &[String],
127    source: Option<&str>,
128    externally_imported_names: &HashSet<String>,
129) -> Vec<LintDiagnostic> {
130    lint_full(
131        program,
132        disabled_rules,
133        source,
134        externally_imported_names,
135        &LintOptions::default(),
136        None,
137    )
138}
139
140/// Lint with cross-file import awareness driven by [`harn_modules::ModuleGraph`].
141pub fn lint_with_module_graph(
142    program: &[SNode],
143    disabled_rules: &[String],
144    source: Option<&str>,
145    externally_imported_names: &HashSet<String>,
146    module_graph: &harn_modules::ModuleGraph,
147    file_path: &Path,
148    options: &LintOptions<'_>,
149) -> Vec<LintDiagnostic> {
150    lint_full(
151        program,
152        disabled_rules,
153        source,
154        externally_imported_names,
155        options,
156        Some((module_graph, file_path)),
157    )
158}
159
160/// Lint with cross-file import awareness plus extra [`LintOptions`].
161pub fn lint_with_options(
162    program: &[SNode],
163    disabled_rules: &[String],
164    source: Option<&str>,
165    externally_imported_names: &HashSet<String>,
166    options: &LintOptions<'_>,
167) -> Vec<LintDiagnostic> {
168    lint_full(
169        program,
170        disabled_rules,
171        source,
172        externally_imported_names,
173        options,
174        None,
175    )
176}
177
178/// The filename suffix that marks a machine-generated Harn source file:
179/// `<name>.generated.harn`.
180pub const GENERATED_HARN_SUFFIX: &str = ".generated.harn";
181
182/// True if `path` names a machine-generated Harn file (`*.generated.harn`).
183///
184/// Style and declaration lints are skipped for these files because their shape
185/// is owned by the generator (e.g. `harn pg codegen`), not the author: an unused
186/// generated row type or banner comment is noise, not a defect. Type diagnostics
187/// run on a separate path and still apply, and `harn fmt` still formats them.
188///
189/// The signal is the *filename*, deliberately not an in-file `@generated` /
190/// `DO NOT EDIT` comment: a content marker is a one-line lint backdoor any
191/// author can paste in to dodge rules, whereas renaming a hand-written file to
192/// `*.generated.harn` is structural and obvious in review. (Compare Go's
193/// `// Code generated … DO NOT EDIT.` regex and Biome's `@generated`; we trade
194/// their convenience for a signal that cannot be forged in passing.)
195pub fn is_generated_path(path: &Path) -> bool {
196    path.file_name()
197        .and_then(|name| name.to_str())
198        .is_some_and(|name| name.ends_with(GENERATED_HARN_SUFFIX))
199}
200
201fn lint_full(
202    program: &[SNode],
203    disabled_rules: &[String],
204    source: Option<&str>,
205    externally_imported_names: &HashSet<String>,
206    options: &LintOptions<'_>,
207    module_graph: Option<(&harn_modules::ModuleGraph, &Path)>,
208) -> Vec<LintDiagnostic> {
209    // Generated files (`*.generated.harn`) skip style/declaration lints entirely.
210    // Type diagnostics flow through a separate path, so real correctness errors
211    // are never hidden.
212    if options.file_path.is_some_and(is_generated_path) {
213        return Vec::new();
214    }
215    let mut linter = Linter::new(source);
216    // Append project rule-engine rules to the registry. They run in the
217    // whole-program phase over the source; a malformed one is skipped.
218    for engine_source in options.engine_rules {
219        if let Some(rule) = crate::engine_rule::EngineRule::from_toml(engine_source) {
220            linter.rules.push(Box::new(rule));
221        }
222    }
223    let (mut native_rules, mut native_load_diagnostics) =
224        crate::native_rule::load_rules_from_paths(options.native_rule_paths);
225    linter.rules.append(&mut native_rules);
226    linter.rules_visit_nodes = linter.rules.iter().any(|rule| rule.visits_nodes());
227    linter.diagnostics.append(&mut native_load_diagnostics);
228    linter.file_path = options.file_path.map(Path::to_path_buf);
229    linter
230        .externally_imported_names
231        .clone_from(externally_imported_names);
232    if let Some((module_graph, file_path)) = module_graph {
233        linter.use_module_graph_for_wildcards = true;
234        linter.module_graph_wildcard_exports = match module_graph.wildcard_exports_for(file_path) {
235            WildcardResolution::Resolved(exports) => Some(exports),
236            WildcardResolution::Unknown => None,
237        };
238    }
239    if let Some(threshold) = options.complexity_threshold {
240        linter.complexity_threshold = threshold;
241    }
242    linter.require_stdlib_metadata = options.require_stdlib_metadata;
243    linter.require_docstrings = options.require_docstrings;
244    linter
245        .persona_step_allowlist
246        .extend(options.persona_step_allowlist.iter().cloned());
247    linter.lint_program(program);
248    if let Some(src) = source {
249        if options.require_file_header {
250            check_require_file_header(src, options.file_path, &mut linter.diagnostics);
251        }
252    }
253    linter.finalize();
254    let mut diagnostics: Vec<LintDiagnostic> = if disabled_rules.is_empty() {
255        linter.diagnostics
256    } else {
257        linter
258            .diagnostics
259            .into_iter()
260            .filter(|d| !rule_disabled(&d.rule, disabled_rules))
261            .collect()
262    };
263    // Per-rule severity overrides apply after disable-filtering.
264    if !options.severity_overrides.is_empty() {
265        for diagnostic in &mut diagnostics {
266            if let Some(&severity) = options.severity_overrides.get(diagnostic.rule.as_ref()) {
267                diagnostic.severity = severity;
268            }
269        }
270    }
271    diagnostics
272}
273
274/// Convert type-checker diagnostics tagged as lint rules into ordinary
275/// lint diagnostics so CLI/editor callers can share rule filtering,
276/// rendering, and autofix plumbing.
277pub fn lint_diagnostics_from_type_diagnostics(
278    diagnostics: &[harn_parser::TypeDiagnostic],
279    disabled_rules: &[String],
280) -> Vec<LintDiagnostic> {
281    diagnostics
282        .iter()
283        .filter_map(type_diagnostic_as_lint)
284        .filter(|diagnostic| !rule_disabled(&diagnostic.rule, disabled_rules))
285        .collect()
286}
287
288/// Returns true when a type diagnostic is a lint diagnostic disabled by
289/// the caller's lint configuration.
290pub fn type_diagnostic_lint_disabled(
291    diagnostic: &harn_parser::TypeDiagnostic,
292    disabled_rules: &[String],
293) -> bool {
294    type_diagnostic_lint_rule(diagnostic).is_some_and(|rule| rule_disabled(rule, disabled_rules))
295}
296
297fn type_diagnostic_as_lint(diagnostic: &harn_parser::TypeDiagnostic) -> Option<LintDiagnostic> {
298    let rule = type_diagnostic_lint_rule(diagnostic)?;
299    let span = diagnostic.span?;
300    Some(LintDiagnostic {
301        code: diagnostic.code,
302        rule: rule.into(),
303        message: diagnostic.message.clone(),
304        span,
305        severity: match diagnostic.severity {
306            harn_parser::DiagnosticSeverity::Warning => LintSeverity::Warning,
307            harn_parser::DiagnosticSeverity::Error => LintSeverity::Error,
308        },
309        suggestion: diagnostic.help.clone(),
310        fix: diagnostic.fix.clone(),
311    })
312}
313
314fn type_diagnostic_lint_rule(diagnostic: &harn_parser::TypeDiagnostic) -> Option<&'static str> {
315    match &diagnostic.details {
316        Some(harn_parser::DiagnosticDetails::LintRule { rule }) => Some(*rule),
317        _ => None,
318    }
319}
320
321fn rule_disabled(rule: &str, disabled_rules: &[String]) -> bool {
322    disabled_rules
323        .iter()
324        .any(|disabled| rule_matches_disabled(rule, disabled))
325}
326
327fn rule_matches_disabled(rule: &str, disabled: &str) -> bool {
328    rule == disabled || (rule == "dead-code-after-return" && disabled == "unreachable-code")
329}
330
331/// Extract all function names that appear in selective import statements
332/// (`import { foo, bar } from "module"`).
333pub fn collect_selective_import_names(program: &[SNode]) -> HashSet<String> {
334    let mut names = HashSet::new();
335    for snode in program {
336        if let harn_parser::Node::SelectiveImport {
337            names: imported, ..
338        } = &snode.node
339        {
340            names.extend(imported.iter().cloned());
341        }
342    }
343    names
344}