Skip to main content

kiln_lint/
lib.rs

1// `LintError` carries paths and captured stderr from the slang invocation.
2#![allow(clippy::result_large_err)]
3//! Linting for `kiln`. Drives `slang-rs` for fast (sub-second) elaboration
4//! checks; reuses [`kiln_build::BuildDiagnostic`] so `kiln check` and `kiln
5//! build` render identically.
6
7use std::collections::BTreeMap;
8
9use thiserror::Error;
10
11use kiln_build::{BuildDiagnostic, Severity, SourceSet};
12use kiln_core::{LintSeverity, ResolvedConfig};
13use slang_rs::{CompileRequest, Severity as SlangSeverity, Slang, SlangError};
14
15#[derive(Debug, Error)]
16pub enum LintError {
17    #[error(transparent)]
18    Slang(#[from] SlangError),
19}
20
21/// Run a Slang elaboration over the project's source set and apply the
22/// `[lint]` severity overrides from the resolved config.
23pub fn check(
24    slang: &Slang,
25    resolved: &ResolvedConfig,
26    source_set: &SourceSet,
27) -> Result<Vec<BuildDiagnostic>, LintError> {
28    let req = build_request(resolved, source_set);
29    let result = slang.compile(&req)?;
30    let diagnostics = result
31        .diagnostics
32        .into_iter()
33        .filter_map(|d| convert(d, &resolved.lint.rules))
34        .collect();
35    Ok(diagnostics)
36}
37
38/// Build the slang CompileRequest for a resolved config. Extracted for testing.
39pub(crate) fn build_request(resolved: &ResolvedConfig, source_set: &SourceSet) -> CompileRequest {
40    use kiln_core::SvLanguage;
41
42    let mut req = CompileRequest::builder().top(&resolved.design.top);
43    // Slang accepts multiple `--top` flags. Auxiliary tops (e.g. Xilinx
44    // `glbl`) are passed via extra_arg to keep the slang-rs builder API
45    // single-top.
46    for aux in &resolved.design.aux_tops {
47        req = req.extra_arg("--top".to_string());
48        req = req.extra_arg(aux.clone());
49    }
50    for s in source_set.files() {
51        req = req.source(s.clone());
52    }
53    for d in &resolved.design.include_dirs {
54        req = req.include_dir(source_set.project_root.join(d));
55    }
56    for (k, v) in &resolved.design.defines {
57        req = req.define(k.clone(), v.clone());
58    }
59    if let Some(ts) = &resolved.design.timescale {
60        req = req.extra_arg("--timescale".to_string());
61        req = req.extra_arg(ts.clone());
62    }
63    if let Some(lang) = resolved.design.language {
64        let flag = match lang {
65            SvLanguage::Sv2005 => "1364-2005",
66            SvLanguage::Sv2009 => "1800-2009",
67            SvLanguage::Sv2012 => "1800-2012",
68            SvLanguage::Sv2017 => "1800-2017",
69            SvLanguage::Sv2023 => "1800-2023",
70        };
71        req = req.extra_arg("--std".to_string());
72        req = req.extra_arg(flag.to_string());
73    }
74    for lib in &resolved.design.libraries {
75        req = req.extra_arg("-y".to_string());
76        req = req.extra_arg(lib.clone());
77    }
78    // Enable every `-W` knob the user asked us to surface.
79    for (id, sev) in &resolved.lint.rules {
80        if matches!(sev, LintSeverity::Error | LintSeverity::Warn) {
81            req = req.extra_arg(format!("-W{id}"));
82        }
83    }
84    for arg in &resolved.tool_slang.extra_args {
85        req = req.extra_arg(arg.clone());
86    }
87    // We do *not* pass `--parse-only` here. Slang skips writing the
88    // `--diag-json` file when parse-only is set, and we want full
89    // elaboration anyway so semantic warnings fire.
90    req.build()
91}
92
93fn convert(
94    d: slang_rs::Diagnostic,
95    rules: &BTreeMap<String, LintSeverity>,
96) -> Option<BuildDiagnostic> {
97    let mut severity = match d.severity {
98        SlangSeverity::Error => Severity::Error,
99        SlangSeverity::Warning => Severity::Warning,
100        SlangSeverity::Note => Severity::Note,
101    };
102    // Apply the per-rule override, if any. `allow` drops the diagnostic.
103    if let Some(name) = &d.option_name {
104        if let Some(over) = rules.get(name) {
105            match over {
106                LintSeverity::Error => severity = Severity::Error,
107                LintSeverity::Warn => severity = Severity::Warning,
108                LintSeverity::Off | LintSeverity::Deny => return None,
109            }
110        }
111    }
112    let (file, line, column) = match d.location {
113        Some(loc) => (Some(loc.file), Some(loc.line), Some(loc.column)),
114        None => (None, None, None),
115    };
116    Some(BuildDiagnostic {
117        severity,
118        code: d.option_name,
119        file,
120        line,
121        column,
122        message: d.message,
123    })
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129    use slang_rs::{Diagnostic as SlangDiag, Location};
130
131    fn diag(option: Option<&str>, sev: SlangSeverity) -> SlangDiag {
132        SlangDiag {
133            severity: sev,
134            message: "msg".to_string(),
135            option_name: option.map(String::from),
136            location: Some(Location {
137                file: "f.sv".into(),
138                line: 1,
139                column: 1,
140            }),
141            symbol_path: None,
142        }
143    }
144
145    #[test]
146    fn convert_no_rule_passes_severity_through() {
147        let rules = BTreeMap::new();
148        let d = convert(diag(Some("foo"), SlangSeverity::Warning), &rules).unwrap();
149        assert_eq!(d.severity, Severity::Warning);
150        assert_eq!(d.code.as_deref(), Some("foo"));
151    }
152
153    #[test]
154    fn convert_error_override_promotes_warning() {
155        let mut rules = BTreeMap::new();
156        rules.insert("width-trunc".to_string(), LintSeverity::Error);
157        let d = convert(diag(Some("width-trunc"), SlangSeverity::Warning), &rules).unwrap();
158        assert_eq!(d.severity, Severity::Error);
159    }
160
161    #[test]
162    fn convert_warn_override_demotes_error() {
163        let mut rules = BTreeMap::new();
164        rules.insert("foo".to_string(), LintSeverity::Warn);
165        let d = convert(diag(Some("foo"), SlangSeverity::Error), &rules).unwrap();
166        assert_eq!(d.severity, Severity::Warning);
167    }
168
169    #[test]
170    fn convert_off_drops_diagnostic() {
171        let mut rules = BTreeMap::new();
172        rules.insert("foo".to_string(), LintSeverity::Off);
173        assert!(convert(diag(Some("foo"), SlangSeverity::Warning), &rules).is_none());
174    }
175
176    #[test]
177    fn convert_without_option_name_uses_native_severity() {
178        let rules = BTreeMap::new();
179        let d = convert(diag(None, SlangSeverity::Error), &rules).unwrap();
180        assert_eq!(d.severity, Severity::Error);
181        assert_eq!(d.code, None);
182    }
183
184    #[test]
185    fn lint_config_round_trips_in_manifest() {
186        use kiln_core::Manifest;
187        let m: Manifest = r#"
188            [package]
189            name = "p"
190            version = "0.1.0"
191
192            [design]
193            top = "t"
194
195            [lint]
196            width-trunc = "error"
197            unused-net = "warn"
198            implicit-net = "off"
199        "#
200        .parse()
201        .unwrap();
202        assert_eq!(m.lint.rules.len(), 3);
203        assert_eq!(m.lint.rules.get("width-trunc"), Some(&LintSeverity::Error));
204        assert_eq!(m.lint.rules.get("implicit-net"), Some(&LintSeverity::Off));
205    }
206
207    fn resolved(manifest_str: &str) -> (kiln_core::ResolvedConfig, kiln_build::SourceSet) {
208        let m: kiln_core::Manifest = manifest_str.parse().unwrap();
209        let resolved = kiln_core::ResolvedConfig::resolve(&m, "dev");
210        let ss = kiln_build::SourceSet {
211            project_root: std::path::PathBuf::from("/p"),
212            files: vec![],
213        };
214        (resolved, ss)
215    }
216
217    #[test]
218    fn timescale_in_design_reaches_slang_args() {
219        let (r, ss) = resolved(
220            r#"
221            [package]
222            name = "p"
223            version = "0.1.0"
224            [design]
225            top = "t"
226            timescale = "1ns/1ps"
227            "#,
228        );
229        let req = build_request(&r, &ss);
230        let args = req.extra_args();
231        let pos = args.iter().position(|a| a == "--timescale");
232        assert!(
233            pos.is_some(),
234            "--timescale not found in slang args: {args:?}"
235        );
236        assert_eq!(args[pos.unwrap() + 1], "1ns/1ps");
237    }
238
239    #[test]
240    fn language_in_design_reaches_slang_std_arg() {
241        let (r, ss) = resolved(
242            r#"
243            [package]
244            name = "p"
245            version = "0.1.0"
246            [design]
247            top = "t"
248            language = "sv2017"
249            "#,
250        );
251        let req = build_request(&r, &ss);
252        let args = req.extra_args();
253        let pos = args.iter().position(|a| a == "--std");
254        assert!(pos.is_some(), "--std not found in slang args: {args:?}");
255        assert_eq!(args[pos.unwrap() + 1], "1800-2017");
256    }
257
258    #[test]
259    fn libraries_in_design_reach_slang_y_flag() {
260        let (r, ss) = resolved(
261            r#"
262            [package]
263            name = "p"
264            version = "0.1.0"
265            [design]
266            top = "t"
267            libraries = ["vendor/lib"]
268            "#,
269        );
270        let req = build_request(&r, &ss);
271        let args = req.extra_args();
272        let pos = args.iter().position(|a| a == "-y");
273        assert!(pos.is_some(), "-y not found in slang args: {args:?}");
274        assert_eq!(args[pos.unwrap() + 1], "vendor/lib");
275    }
276
277    #[test]
278    fn aux_tops_become_additional_top_flags() {
279        let (r, ss) = resolved(
280            r#"
281            [package]
282            name = "p"
283            version = "0.1.0"
284            [design]
285            top = "z1top"
286            aux_tops = ["glbl", "BUFG_helper"]
287            "#,
288        );
289        let req = build_request(&r, &ss);
290        let args = req.extra_args();
291        // Two `--top` extra args, with the aux names following.
292        let positions: Vec<usize> = args
293            .iter()
294            .enumerate()
295            .filter(|(_, a)| a.as_str() == "--top")
296            .map(|(i, _)| i)
297            .collect();
298        assert_eq!(positions.len(), 2, "expected 2 extra `--top`: {args:?}");
299        assert_eq!(args[positions[0] + 1], "glbl");
300        assert_eq!(args[positions[1] + 1], "BUFG_helper");
301    }
302
303    #[test]
304    fn tool_slang_extra_args_appended_last() {
305        let (r, ss) = resolved(
306            r#"
307            [package]
308            name = "p"
309            version = "0.1.0"
310            [design]
311            top = "t"
312            timescale = "1ns/1ps"
313            [tool.slang]
314            extra_args = ["--allow-hierarchical-const"]
315            "#,
316        );
317        let req = build_request(&r, &ss);
318        let args = req.extra_args();
319        // extra_args must come after timescale so users can override
320        let ts_pos = args.iter().position(|a| a == "--timescale").unwrap();
321        let extra_pos = args
322            .iter()
323            .position(|a| a == "--allow-hierarchical-const")
324            .unwrap();
325        assert!(
326            extra_pos > ts_pos,
327            "extra_args should come after design args"
328        );
329    }
330}