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
201/// True when `path` points at Harn's canonical embedded stdlib source tree.
202///
203/// Stdlib contract lints are intentionally path-driven: files under
204/// `crates/harn-stdlib/src/stdlib/` are owned by Harn itself and must carry
205/// stable producer contracts, while user scripts and package sources should not
206/// inherit those repo-internal authoring rules.
207pub fn path_is_stdlib_source(path: &Path) -> bool {
208    use std::path::Component;
209
210    let mut prev: Option<&std::ffi::OsStr> = None;
211    let mut prev_prev: Option<&std::ffi::OsStr> = None;
212    for comp in path.components() {
213        if let Component::Normal(name) = comp {
214            if prev == Some(std::ffi::OsStr::new("src"))
215                && prev_prev == Some(std::ffi::OsStr::new("harn-stdlib"))
216                && name == std::ffi::OsStr::new("stdlib")
217            {
218                return true;
219            }
220            prev_prev = prev;
221            prev = Some(name);
222        } else {
223            prev_prev = prev;
224            prev = None;
225        }
226    }
227    false
228}
229
230fn lint_full(
231    program: &[SNode],
232    disabled_rules: &[String],
233    source: Option<&str>,
234    externally_imported_names: &HashSet<String>,
235    options: &LintOptions<'_>,
236    module_graph: Option<(&harn_modules::ModuleGraph, &Path)>,
237) -> Vec<LintDiagnostic> {
238    // Generated files (`*.generated.harn`) skip style/declaration lints entirely.
239    // Type diagnostics flow through a separate path, so real correctness errors
240    // are never hidden.
241    if options.file_path.is_some_and(is_generated_path) {
242        return Vec::new();
243    }
244    let mut linter = Linter::new(source);
245    // Append project rule-engine rules to the registry. They run in the
246    // whole-program phase over the source; a malformed one is skipped.
247    for engine_source in options.engine_rules {
248        if let Some(rule) = crate::engine_rule::EngineRule::from_toml(engine_source) {
249            linter.rules.push(Box::new(rule));
250        }
251    }
252    let (mut native_rules, mut native_load_diagnostics) =
253        crate::native_rule::load_rules_from_paths(options.native_rule_paths);
254    linter.rules.append(&mut native_rules);
255    linter.rules_visit_nodes = linter.rules.iter().any(|rule| rule.visits_nodes());
256    linter.diagnostics.append(&mut native_load_diagnostics);
257    linter.file_path = options.file_path.map(Path::to_path_buf);
258    linter
259        .externally_imported_names
260        .clone_from(externally_imported_names);
261    if let Some((module_graph, file_path)) = module_graph {
262        linter.use_module_graph_for_wildcards = true;
263        linter.module_graph_wildcard_exports = match module_graph.wildcard_exports_for(file_path) {
264            WildcardResolution::Resolved(exports) => Some(exports),
265            WildcardResolution::Unknown => None,
266        };
267    }
268    if let Some(threshold) = options.complexity_threshold {
269        linter.complexity_threshold = threshold;
270    }
271    linter.require_stdlib_metadata = options.require_stdlib_metadata;
272    linter.require_docstrings = options.require_docstrings;
273    linter
274        .persona_step_allowlist
275        .extend(options.persona_step_allowlist.iter().cloned());
276    linter.lint_program(program);
277    if let Some(src) = source {
278        if options.require_file_header {
279            check_require_file_header(src, options.file_path, &mut linter.diagnostics);
280        }
281    }
282    linter.finalize();
283    let mut diagnostics: Vec<LintDiagnostic> = if disabled_rules.is_empty() {
284        linter.diagnostics
285    } else {
286        linter
287            .diagnostics
288            .into_iter()
289            .filter(|d| !rule_disabled(&d.rule, disabled_rules))
290            .collect()
291    };
292    // Per-rule severity overrides apply after disable-filtering.
293    if !options.severity_overrides.is_empty() {
294        for diagnostic in &mut diagnostics {
295            if let Some(&severity) = options.severity_overrides.get(diagnostic.rule.as_ref()) {
296                diagnostic.severity = severity;
297            }
298        }
299    }
300    diagnostics
301}
302
303/// Convert type-checker diagnostics tagged as lint rules into ordinary
304/// lint diagnostics so CLI/editor callers can share rule filtering,
305/// rendering, and autofix plumbing.
306pub fn lint_diagnostics_from_type_diagnostics(
307    diagnostics: &[harn_parser::TypeDiagnostic],
308    disabled_rules: &[String],
309) -> Vec<LintDiagnostic> {
310    diagnostics
311        .iter()
312        .filter_map(type_diagnostic_as_lint)
313        .filter(|diagnostic| !rule_disabled(&diagnostic.rule, disabled_rules))
314        .collect()
315}
316
317/// Returns true when a type diagnostic is a lint diagnostic disabled by
318/// the caller's lint configuration.
319pub fn type_diagnostic_lint_disabled(
320    diagnostic: &harn_parser::TypeDiagnostic,
321    disabled_rules: &[String],
322) -> bool {
323    type_diagnostic_lint_rule(diagnostic).is_some_and(|rule| rule_disabled(rule, disabled_rules))
324}
325
326fn type_diagnostic_as_lint(diagnostic: &harn_parser::TypeDiagnostic) -> Option<LintDiagnostic> {
327    let rule = type_diagnostic_lint_rule(diagnostic)?;
328    let span = diagnostic.span?;
329    Some(LintDiagnostic {
330        code: diagnostic.code,
331        rule: rule.into(),
332        message: diagnostic.message.clone(),
333        span,
334        severity: match diagnostic.severity {
335            harn_parser::DiagnosticSeverity::Warning => LintSeverity::Warning,
336            harn_parser::DiagnosticSeverity::Error => LintSeverity::Error,
337        },
338        suggestion: diagnostic.help.clone(),
339        fix: diagnostic.fix.clone(),
340    })
341}
342
343fn type_diagnostic_lint_rule(diagnostic: &harn_parser::TypeDiagnostic) -> Option<&'static str> {
344    match &diagnostic.details {
345        Some(harn_parser::DiagnosticDetails::LintRule { rule }) => Some(*rule),
346        _ => None,
347    }
348}
349
350fn rule_disabled(rule: &str, disabled_rules: &[String]) -> bool {
351    disabled_rules
352        .iter()
353        .any(|disabled| rule_matches_disabled(rule, disabled))
354}
355
356fn rule_matches_disabled(rule: &str, disabled: &str) -> bool {
357    rule == disabled || (rule == "dead-code-after-return" && disabled == "unreachable-code")
358}
359
360/// Extract all function names that appear in selective import statements
361/// (`import { foo, bar } from "module"`).
362pub fn collect_selective_import_names(program: &[SNode]) -> HashSet<String> {
363    let mut names = HashSet::new();
364    for snode in program {
365        if let harn_parser::Node::SelectiveImport {
366            names: imported, ..
367        } = &snode.node
368        {
369            names.extend(imported.iter().cloned());
370        }
371    }
372    names
373}