Skip to main content

hedl_lint/
lib.rs

1// Dweve HEDL - Hierarchical Entity Data Language
2//
3// Copyright (c) 2025 Dweve IP B.V. and individual contributors.
4//
5// SPDX-License-Identifier: Apache-2.0
6//
7// Licensed under the Apache License, Version 2.0 (the "License");
8// you may not use this file except in compliance with the License.
9// You may obtain a copy of the License in the LICENSE file at the
10// root of this repository or at: http://www.apache.org/licenses/LICENSE-2.0
11//
12// Unless required by applicable law or agreed to in writing, software
13// distributed under the License is distributed on an "AS IS" BASIS,
14// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15// See the License for the specific language governing permissions and
16// limitations under the License.
17
18//! HEDL Linting
19//!
20//! Provides extensible linting and best practices validation for HEDL documents.
21//!
22//! ## Quick Start
23//!
24//! ```rust
25//! use hedl_core::Document;
26//! use hedl_lint::{lint, Severity};
27//!
28//! let doc = Document::new((2, 0));
29//! let diagnostics = lint(&doc);
30//!
31//! for diag in &diagnostics {
32//!     if diag.severity() == Severity::Error {
33//!         eprintln!("{}", diag);
34//!     }
35//! }
36//! ```
37//!
38//! ## Custom Configuration
39//!
40//! ```rust
41//! use hedl_lint::{lint_with_config, LintConfig, Severity};
42//! use hedl_core::Document;
43//!
44//! let doc = Document::new((2, 0));
45//!
46//! let mut config = LintConfig::default();
47//! config.disable_rule("id-naming");
48//! config.set_rule_error("unused-schema");
49//! config.min_severity = Severity::Warning;
50//!
51//! let diagnostics = lint_with_config(&doc, config);
52//! ```
53//!
54//! ## Custom Rules
55//!
56//! ```rust
57//! use hedl_lint::{LintRule, Diagnostic, DiagnosticKind, LintRunner, LintConfig};
58//! use hedl_core::Document;
59//!
60//! struct MyCustomRule;
61//!
62//! impl LintRule for MyCustomRule {
63//!     fn id(&self) -> &str { "my-custom-rule" }
64//!     fn description(&self) -> &str { "Custom validation logic" }
65//!     fn check(&self, doc: &Document) -> Vec<Diagnostic> {
66//!         vec![]
67//!     }
68//! }
69//!
70//! let mut runner = LintRunner::new(LintConfig::default());
71//! runner.add_rule(Box::new(MyCustomRule));
72//!
73//! let doc = Document::new((2, 0));
74//! let diagnostics = runner.run(&doc);
75//! ```
76//!
77//! ## Using `LintContext`
78//!
79//! ```rust
80//! use hedl_lint::{LintContext, LintRunner, LintConfig};
81//! use hedl_core::Document;
82//! use std::path::PathBuf;
83//!
84//! let doc = Document::new((2, 0));
85//! let runner = LintRunner::new(LintConfig::default());
86//!
87//! // Create context with file path and source text
88//! let context = LintContext::with_file(
89//!     PathBuf::from("data.hedl"),
90//!     "source content here"
91//! ).with_line(42);
92//!
93//! let diagnostics = runner.run_with_context(&doc, context);
94//! ```
95//!
96//! ## Performance
97//!
98//! The linter is optimized for large documents through:
99//! - **Parallel rule execution** (enabled by default with `parallel` feature)
100//! - **Early termination** when diagnostic limits are reached
101//! - **Memory pre-allocation** to reduce allocations
102//! - **Single-pass traversal** option for specific use cases
103//!
104//! For large documents (> 10K nodes), parallel execution provides 3-4x speedup
105//! on multi-core systems.
106
107#![cfg_attr(not(test), warn(missing_docs))]
108mod diagnostic;
109/// Auto-fix functionality.
110pub mod fix;
111mod rules;
112mod runner;
113mod visitor;
114
115pub use diagnostic::{Diagnostic, DiagnosticKind, Severity};
116pub use fix::{
117    Fix, FixApplicator, FixConfig, FixContext, FixError, FixId, FixProvider, FixResult,
118    FixStatistics, SourcePosition, SourceRange,
119};
120pub use rules::{LintRule, RuleConfig};
121pub use runner::{LintConfig, LintContext, LintRunner};
122
123use hedl_core::Document;
124
125/// Run all default lint rules on a document
126#[must_use]
127pub fn lint(doc: &Document) -> Vec<Diagnostic> {
128    let runner = LintRunner::new(LintConfig::default());
129    runner.run(doc)
130}
131
132/// Run lint with custom configuration
133#[must_use]
134pub fn lint_with_config(doc: &Document, config: LintConfig) -> Vec<Diagnostic> {
135    let runner = LintRunner::new(config);
136    runner.run(doc)
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142    use hedl_core::{Item, MatrixList, Node, Reference, Value};
143
144    #[test]
145    fn test_lint_empty_document() {
146        let doc = Document::new((2, 0));
147        let diagnostics = lint(&doc);
148        assert_eq!(diagnostics.len(), 0);
149    }
150
151    #[test]
152    fn test_lint_short_ids() {
153        let mut doc = Document::new((2, 0));
154
155        // Create a matrix list with short IDs
156        let mut list = MatrixList::new("User", vec!["id".to_string(), "name".to_string()]);
157        let node1 = Node::new("User", "a", vec![Value::String("Alice".to_string().into())]);
158        let node2 = Node::new("User", "b", vec![Value::String("Bob".to_string().into())]);
159        list.add_row(node1);
160        list.add_row(node2);
161
162        doc.root.insert("users".to_string(), Item::List(list));
163
164        let diagnostics = lint(&doc);
165
166        // Should have 2 hints for short IDs
167        let short_id_hints: Vec<_> = diagnostics
168            .iter()
169            .filter(|d| matches!(d.kind(), DiagnosticKind::IdNaming))
170            .collect();
171        assert_eq!(short_id_hints.len(), 2);
172        assert!(short_id_hints
173            .iter()
174            .all(|d| d.severity() == Severity::Hint));
175    }
176
177    #[test]
178    fn test_lint_numeric_ids() {
179        let mut doc = Document::new((2, 0));
180
181        let mut list = MatrixList::new("Item", vec!["id".to_string(), "value".to_string()]);
182        let node = Node::new("Item", "123", vec![Value::Int(100)]);
183        list.add_row(node);
184
185        doc.root.insert("items".to_string(), Item::List(list));
186
187        let diagnostics = lint(&doc);
188
189        let numeric_id_hints: Vec<_> = diagnostics
190            .iter()
191            .filter(|d| matches!(d.kind(), DiagnosticKind::IdNaming))
192            .collect();
193        assert!(!numeric_id_hints.is_empty());
194    }
195
196    #[test]
197    fn test_lint_unused_schema() {
198        let mut doc = Document::new((2, 0));
199
200        // Define a schema that's never used
201        doc.structs.insert(
202            "UnusedType".to_string(),
203            vec!["id".to_string(), "name".to_string()],
204        );
205
206        // Add a used schema
207        doc.structs
208            .insert("UsedType".to_string(), vec!["id".to_string()]);
209        let mut list = MatrixList::new("UsedType", vec!["id".to_string()]);
210        list.add_row(Node::new("UsedType", "test", vec![]));
211        doc.root.insert("used".to_string(), Item::List(list));
212
213        let diagnostics = lint(&doc);
214
215        let unused_schema_warnings: Vec<_> = diagnostics
216            .iter()
217            .filter(|d| matches!(d.kind(), DiagnosticKind::UnusedSchema))
218            .collect();
219        assert_eq!(unused_schema_warnings.len(), 1);
220        assert_eq!(unused_schema_warnings[0].severity(), Severity::Warning);
221    }
222
223    #[test]
224    fn test_lint_empty_list() {
225        let mut doc = Document::new((2, 0));
226
227        let list = MatrixList::new("EmptyType", vec!["id".to_string()]);
228        doc.root.insert("empty_list".to_string(), Item::List(list));
229
230        let diagnostics = lint(&doc);
231
232        let empty_list_hints: Vec<_> = diagnostics
233            .iter()
234            .filter(|d| matches!(d.kind(), DiagnosticKind::EmptyList))
235            .collect();
236        assert_eq!(empty_list_hints.len(), 1);
237        assert_eq!(empty_list_hints[0].severity(), Severity::Hint);
238    }
239
240    #[test]
241    fn test_lint_unqualified_reference() {
242        let mut doc = Document::new((2, 0));
243
244        // Add an unqualified reference in key-value context
245        let ref_value = Value::Reference(Reference::local("some_id"));
246        doc.root
247            .insert("ref_field".to_string(), Item::Scalar(ref_value));
248
249        let diagnostics = lint(&doc);
250
251        let unqualified_ref_warnings: Vec<_> = diagnostics
252            .iter()
253            .filter(|d| matches!(d.kind(), DiagnosticKind::UnqualifiedKvReference))
254            .collect();
255        assert_eq!(unqualified_ref_warnings.len(), 1);
256        assert_eq!(unqualified_ref_warnings[0].severity(), Severity::Warning);
257        assert!(unqualified_ref_warnings[0].suggestion().is_some());
258    }
259
260    #[test]
261    fn test_lint_config_disable_rule() {
262        let mut doc = Document::new((2, 0));
263
264        let list = MatrixList::new("EmptyType", vec!["id".to_string()]);
265        doc.root.insert("empty_list".to_string(), Item::List(list));
266
267        // Disable the empty-list rule
268        let mut config = LintConfig::default();
269        config.disable_rule("empty-list");
270
271        let diagnostics = lint_with_config(&doc, config);
272
273        let empty_list_hints: Vec<_> = diagnostics
274            .iter()
275            .filter(|d| matches!(d.kind(), DiagnosticKind::EmptyList))
276            .collect();
277        assert_eq!(empty_list_hints.len(), 0);
278    }
279
280    #[test]
281    fn test_diagnostic_display() {
282        let diag = Diagnostic::warning(DiagnosticKind::UnusedSchema, "Test message", "test-rule");
283        let display = format!("{diag}");
284        assert!(display.contains("warning"));
285        assert!(display.contains("Test message"));
286        assert!(display.contains("test-rule"));
287    }
288
289    #[test]
290    fn test_diagnostic_with_line() {
291        let diag = Diagnostic::error(
292            DiagnosticKind::Custom("dup-key".to_string()),
293            "Duplicate key found",
294            "dup-key",
295        )
296        .with_line(42);
297
298        let display = format!("{diag}");
299        assert!(display.contains("line 42"));
300    }
301}