Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use pyo3::prelude::{Bound, PyAny, PyAnyMethods, PyModule, PyModuleMethods, PyRes
use pyo3::types::{PyBool, PyDict, PyDictMethods, PyString, PyTuple, PyTupleMethods};
use pyo3::{IntoPyObject, pyfunction, wrap_pyfunction};
use sqlbuild_host::entries::models::ExecutorHost;
use sqlbuild_rules::lint_text::main::counts_toward_exit::counts_toward_exit;

use crate::bindings::_helpers::boundary::python_calls::{
Status, call_with, imported, keywords, within,
Expand Down Expand Up @@ -546,14 +547,15 @@ impl<'py> Driver<'_, 'py> {
}
}

/// The core diagnostics followed by one rule-phase error per rule finding.
/// The core diagnostics followed by one rule-phase error per rule finding; lint-unavailable ones only warn.
fn with_rule_diagnostics<'py>(
py: Python<'py>,
core: &Bound<'py, PyTuple>,
rules_result: &Bound<'py, PyAny>,
) -> PyResult<Bound<'py, PyTuple>> {
let phase: Bound<'py, PyAny> = enum_member(py, RecordId::DiagnosticPhase, "RULE")?;
let severity: Bound<'py, PyAny> = enum_member(py, RecordId::DiagnosticSeverity, "ERROR")?;
let unavailable: Bound<'py, PyAny> = enum_member(py, RecordId::DiagnosticSeverity, "WARNING")?;
let fix_note: Bound<'py, PyAny> = PyString::new(py, RULE_FIX_AVAILABLE_NOTE).into_any();
let mut diagnostics: Vec<Bound<'py, PyAny>> = core.iter().collect();
for fault in native_field(rules_result, "findings")?.try_iter()? {
Expand All @@ -568,7 +570,13 @@ fn with_rule_diagnostics<'py>(
RecordId::CompilerDiagnostic,
&[
("phase", phase.clone()),
("severity", severity.clone()),
(
"severity",
match counts_toward_exit(&native_field(&fault, "code")?.extract::<String>()?) {
true => severity.clone(),
false => unavailable.clone(),
},
),
("code", native_field(&fault, "code")?),
("message", native_field(&fault, "message")?),
("path", native_field(&fault, "path")?),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
//! The compile-and-plan pipeline of execution commands: compile, rules gate, then planning.

use pyo3::prelude::{Bound, PyAny, PyAnyMethods, PyModule, PyModuleMethods, PyResult, Python};
use pyo3::types::PyDict;
use pyo3::types::{PyDict, PyTuple};
use pyo3::{IntoPyObject, PyErr, pyfunction, wrap_pyfunction};
use sqlbuild_host::entries::models::ExecutorHost;
use sqlbuild_rules::lint_text::main::counts_toward_exit::counts_toward_exit;
use sqlbuild_rules::rule_results::main::default_rules_notice::default_rules_notice;

use crate::bindings::_helpers::boundary::python_calls::{KeywordArguments, keywords};
Expand Down Expand Up @@ -135,10 +136,17 @@ fn run_rules_gate<'py>(
let message: String = rules_progress(elapsed, &result, true)?;
on_progress.call1((message,))?;
}
let findings: Bound<'py, PyAny> = native_field(&result, "findings")?;
if !findings.is_truthy()? {
let mut failing: Vec<Bound<'py, PyAny>> = Vec::new();
for finding in native_field(&result, "findings")?.try_iter()? {
let finding: Bound<'py, PyAny> = finding?;
if counts_toward_exit(&native_field(&finding, "code")?.extract::<String>()?) {
failing.push(finding);
}
}
if failing.is_empty() {
return Ok(());
}
let findings: Bound<'py, PyAny> = PyTuple::new(py, failing)?.into_any();
let summary: Bound<'py, PyAny> = new_record_with(
py,
RecordId::RulesResult,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use pyo3::prelude::{
};
use pyo3::types::{PyDict, PyDictMethods, PyTuple};
use pyo3::wrap_pyfunction;
use sqlbuild_rules::lint_text::main::counts_toward_exit::counts_toward_exit;
use sqlbuild_rules::rule_catalogue::main::render_rule_list::render_rule_list;
use sqlbuild_rules::rule_catalogue::models::RuleListing;

Expand Down Expand Up @@ -246,7 +247,12 @@ fn run_rule_selection(
native_field(request, "json_output")?.is_truthy()?,
)?;
print_line(py, &text, false)?;
let findings: usize = native_field(&result, "findings")?.len()?;
let mut findings: usize = 0;
for finding in native_field(&result, "findings")?.try_iter()? {
if counts_toward_exit(&native_field(&finding?, "code")?.extract::<String>()?) {
findings += 1;
}
}
if findings > 0 {
print_line(
py,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ use pyo3::prelude::{
use pyo3::types::{PyBytes, PyDict, PyDictMethods, PyString, PyTuple};
use pyo3::{IntoPyObjectExt, pyfunction, wrap_pyfunction};
use sqlbuild_core::text::main::python_splitlines::python_splitlines;
use sqlbuild_rules::lint_text::main::counts_toward_exit::counts_toward_exit;
use sqlbuild_rules::rule_cache::main::file_rule_key::file_rule_key;
use sqlbuild_rules::rule_results::main::duplicate_literal_hints::duplicate_literal_hints;
use sqlbuild_rules::rule_results::models::RuleFinding;
Expand Down Expand Up @@ -670,7 +671,7 @@ fn lint_expansion_context(
})
}

/// The findings of the selected SQL rules' violations; an engine failure leaves each unevaluated.
/// The selected SQL rules' findings and lint-unavailable warnings; an engine failure leaves each rule unevaluated.
fn violation_findings(
run: &SqlRun<'_, '_>,
violations: &[(PathBuf, LintViolationRow)],
Expand All @@ -686,6 +687,8 @@ fn violation_findings(
let code: &str = &violation.code;
if code == SQL_RULE_SUPPRESSION_CODE || run.codes.iter().any(|known| known == code) {
evaluated.push(lint_finding((path, violation), &resolved_dir, model_paths));
} else if !counts_toward_exit(code) {
evaluated.push(lint_finding((path, violation), &resolved_dir, &no_paths));
} else if code == engine_error {
let finding: RuleFinding = lint_finding((path, violation), &resolved_dir, &no_paths);
for rule_code in run.codes {
Expand Down
6 changes: 6 additions & 0 deletions crates/sqlbuild-rules/src/engine/_helpers/evaluation.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use crate::configuration::main::validate as config;
use crate::constants::{API_VERSION, NATIVE_BUILD_IDENTITY, RELATION_EXCEPTION_RULES};
use crate::engine::_helpers::cache::{Cache, PendingBucketWrite, RuleCacheEntry};
use crate::lint_text::constants::LINT_UNAVAILABLE_CODE;
use crate::models::{
EvaluateRequest, EvaluateResponse, Fault, Model, RuleException, RuleMetadata, RuleScope,
RulesCodeGrammar, RulesConfig,
Expand Down Expand Up @@ -580,11 +581,15 @@ pub(crate) fn finalize_findings(
apply_fault_policy(&request, evaluated_codes, findings)
}

/// Apply rule exceptions to `faults`; lint-unavailable warnings report our gap, not a rule, so they pass through unsuppressed.
fn apply_fault_policy(
request: &EvaluateRequest,
evaluated_codes: &[String],
faults: Vec<Fault>,
) -> Result<Vec<Fault>, String> {
let (unavailable, faults): (Vec<Fault>, Vec<Fault>) = faults
.into_iter()
.partition(|fault| fault.code == LINT_UNAVAILABLE_CODE);
let unevaluated: BTreeSet<_> = faults
.iter()
.filter(|fault| fault.unevaluated)
Expand Down Expand Up @@ -641,6 +646,7 @@ fn apply_fault_policy(
));
fault
})
.chain(unavailable)
.collect())
}

Expand Down
79 changes: 73 additions & 6 deletions crates/sqlbuild-rules/src/lint_text/_helpers/sql_diagnostics.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
//! Native SQL lint responses mapped back onto authored source through expansion passes.

use std::borrow::Cow;
use std::collections::HashSet;
use std::sync::LazyLock;

use regex::Regex;
Expand All @@ -10,7 +11,8 @@ use crate::lint_text::_helpers::sites::backtick_identifiers;
use crate::lint_text::_helpers::sites::{byte_offset, compiled, lint_policy, site_end};
use crate::lint_text::constants::{
GENERATED_FIX, GENERATED_SQL_SUFFIX, HARNESS_CTE_PREFIXES, INVALID_DIAGNOSTIC,
INVALID_FIX_EDIT, INVALID_FIX_OBJECT, INVALID_FIX_REASON, LINT_API_VERSION, LONG_LITERAL_CODE,
INVALID_FIX_EDIT, INVALID_FIX_OBJECT, INVALID_FIX_REASON, INVALID_UNAVAILABLE,
LINT_API_VERSION, LINT_UNAVAILABLE_CODE, LINT_UNAVAILABLE_REMEDIATION, LONG_LITERAL_CODE,
MACRO_MIXED_QUALIFICATION_REMEDIATION, MISSING_DIAGNOSTICS, MIXED_QUALIFICATION_CODE,
NATIVE_LINT_ERROR_CODE, NON_CONTIGUOUS_FIX, NON_OBJECT_RESPONSE, NOT_AN_OBJECT,
PARSE_ERROR_POSITION_PATTERN, PARSE_FAILURE_PREFIX, PARSE_FAILURE_REMEDIATION,
Expand Down Expand Up @@ -109,8 +111,15 @@ pub(crate) fn map_offset(offset: i64, passes: &[Vec<ExpansionSpan>]) -> (i64, bo
(current, generated)
}

/// The diagnostics list of one engine response, or the error it reports.
pub(crate) fn response_diagnostics(response: &Value) -> Result<Vec<Value>, String> {
/// The diagnostics list of one engine response, or why its dialect could not be linted.
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum LintResult {
Diagnostics(Vec<Value>),
Unavailable(String),
}

/// The result of one engine response, or the error it reports.
pub(crate) fn response_diagnostics(response: &Value) -> Result<LintResult, String> {
let Some(payload) = response.as_object() else {
return Err(NON_OBJECT_RESPONSE.to_string());
};
Expand All @@ -123,9 +132,67 @@ pub(crate) fn response_diagnostics(response: &Value) -> Result<Vec<Value>, Strin
if integer(result.get("version")) != Some(LINT_API_VERSION) {
return Err(UNSUPPORTED_VERSION.to_string());
}
match result.get("diagnostics") {
Some(Value::Array(diagnostics)) => Ok(diagnostics.clone()),
_ => Err(MISSING_DIAGNOSTICS.to_string()),
match (result.get("unavailable"), result.get("diagnostics")) {
(Some(Value::String(reason)), _) => Ok(LintResult::Unavailable(reason.clone())),
(Some(_), _) => Err(INVALID_UNAVAILABLE.to_string()),
(None, Some(Value::Array(diagnostics))) => Ok(LintResult::Diagnostics(diagnostics.clone())),
(None, _) => Err(MISSING_DIAGNOSTICS.to_string()),
}
}

/// One body's authored diagnostics from its engine response, or the warning that it was not linted.
pub(crate) fn body_diagnostics(
response: &Result<LintResult, String>,
(body, omitted): (&LintSqlBody, Option<usize>),
file: &AuthoredFile<'_>,
) -> Vec<AuthoredDiagnostic> {
let raw: Vec<Value> = match response {
Ok(LintResult::Diagnostics(raw)) => raw.clone(),
Ok(LintResult::Unavailable(reason)) => {
return vec![unavailable_warning(reason, body, file)];
}
Err(message) => return vec![parse_failure(message, body, file)],
};
without_generated_frame(raw, body, omitted)
.iter()
.filter_map(|item| match authored_diagnostic(item, body, file) {
Ok(diagnostic) => diagnostic,
Err(message) => Some(parse_failure(&message, body, file)),
})
.collect()
}

/// The diagnostics with only each file's first lint-unavailable warning.
pub(crate) fn unavailable_once_per_file(
diagnostics: Vec<AuthoredDiagnostic>,
) -> Vec<AuthoredDiagnostic> {
let mut warned: HashSet<usize> = HashSet::new();
diagnostics
.into_iter()
.filter(|diagnostic| {
diagnostic.code != LINT_UNAVAILABLE_CODE || warned.insert(diagnostic.file)
})
.collect()
}

/// The warning that a body's SQL was not linted because the engine cannot parse its dialect.
fn unavailable_warning(
reason: &str,
body: &LintSqlBody,
file: &AuthoredFile<'_>,
) -> AuthoredDiagnostic {
let (line, column): (i64, i64) = file.position(body.body_start);
AuthoredDiagnostic {
file: body.file,
line,
column,
code: LINT_UNAVAILABLE_CODE.to_string(),
message: reason.to_string(),
fault: false,
end: None,
remediation: LINT_UNAVAILABLE_REMEDIATION.to_string(),
fix: None,
fix_unavailable_reason: None,
}
}

Expand Down
6 changes: 6 additions & 0 deletions crates/sqlbuild-rules/src/lint_text/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,10 @@ pub(crate) const CTE_ARGUMENT: &str = "__cte";
/// The help for mixed qualification in a SQL macro body, where slot reads are qualified through an alias.
pub(crate) const MACRO_MIXED_QUALIFICATION_REMEDIATION: &str = "Use one consistent qualification style in this query scope. A plain slot read such as `@rel.column` counts as unqualified; to qualify it, read through an alias: `FROM @rel AS s`, then `s.@rel.column`.";
pub(crate) const NATIVE_LINT_ERROR_CODE: &str = "L003";
/// The warning code for SQL the native lint engine cannot parse in the project's dialect.
pub(crate) const LINT_UNAVAILABLE_CODE: &str = "L007";
pub(crate) const LINT_UNAVAILABLE_REMEDIATION: &str =
"This SQL was not linted. Review it by hand until SQL lint supports this dialect.";
pub(crate) const PARSE_ERROR_POSITION_PATTERN: &str =
r"^Parse error at line (?P<line>\d+), column (?P<column>\d+):";
pub(crate) const HARNESS_CTE_PREFIXES: [&str; 13] = [
Expand Down Expand Up @@ -152,6 +156,8 @@ pub(crate) const NOT_AN_OBJECT: &str = "native lint diagnostic must be an object
pub(crate) const NON_OBJECT_RESPONSE: &str = "native lint engine returned a non-object response";
pub(crate) const UNSUPPORTED_VERSION: &str =
"native lint engine returned an unsupported response version";
pub(crate) const INVALID_UNAVAILABLE: &str =
"native lint response has a non-string unavailable reason";
pub(crate) const MISSING_DIAGNOSTICS: &str = "native lint response is missing a diagnostics list";
pub(crate) const INVALID_BATCH: &str = "native lint engine returned an invalid batch response";
pub(crate) const NON_CONTIGUOUS_FIX: &str =
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
//! `counts_toward_exit`: whether a lint finding can make a command fail.

use crate::lint_text::constants::LINT_UNAVAILABLE_CODE;

/// Whether a finding with `code` counts toward a command's exit status; SQL lint being unavailable for a dialect never does.
pub fn counts_toward_exit(code: &str) -> bool {
code != LINT_UNAVAILABLE_CODE
}
35 changes: 12 additions & 23 deletions crates/sqlbuild-rules/src/lint_text/main/lint_sql_bodies.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@ use std::collections::HashMap;
use serde_json::Value;

use crate::lint_text::_helpers::sql_diagnostics::{
AuthoredFile, authored_diagnostic, parse_failure, response_diagnostics, without_generated_frame,
AuthoredFile, LintResult, body_diagnostics, response_diagnostics, unavailable_once_per_file,
};
use crate::lint_text::_helpers::sql_requests::{lint_payload, omitted_select, run_batch};
use crate::lint_text::models::{AuthoredDiagnostic, LintSqlBody, LintSqlOptions};

/// Lint every body once per distinct request and map each diagnostic onto its authored file.
/// Lint each distinct request once and map its diagnostics, or one unavailable warning per file, onto source.
pub fn lint_sql_bodies(
bodies: &[LintSqlBody],
files: &[&str],
Expand All @@ -30,31 +30,20 @@ pub fn lint_sql_bodies(
}
request_of_body.push(index);
}
let responses: Vec<Result<Vec<Value>, String>> = run_batch(&payloads)
let responses: Vec<Result<LintResult, String>> = run_batch(&payloads)
.map(|responses| responses.iter().map(response_diagnostics).collect())
.unwrap_or_else(|error| vec![Err(error); payloads.len()]);
let authored: Vec<AuthoredFile<'_>> = files
.iter()
.map(|text| AuthoredFile::new(text, &options.dialect))
.collect();
let mut diagnostics: Vec<AuthoredDiagnostic> = Vec::new();
for ((body, offset), request) in bodies.iter().zip(omitted).zip(request_of_body) {
let file: &AuthoredFile<'_> = &authored[body.file];
let raw: Vec<Value> = match &responses[request] {
Ok(raw) => raw.clone(),
Err(message) => {
diagnostics.push(parse_failure(message, body, file));
continue;
}
};
let raw: Vec<Value> = without_generated_frame(raw, body, offset);
for item in &raw {
match authored_diagnostic(item, body, file) {
Ok(Some(diagnostic)) => diagnostics.push(diagnostic),
Ok(None) => {}
Err(message) => diagnostics.push(parse_failure(&message, body, file)),
}
}
}
diagnostics
let diagnostics: Vec<AuthoredDiagnostic> = bodies
.iter()
.zip(omitted)
.zip(request_of_body)
.flat_map(|((body, offset), request)| {
body_diagnostics(&responses[request], (body, offset), &authored[body.file])
})
.collect();
unavailable_once_per_file(diagnostics)
}
1 change: 1 addition & 0 deletions crates/sqlbuild-rules/src/lint_text/main/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pub mod counts_toward_exit;
pub mod defined_cte_names;
pub mod dependency_sites;
pub mod first_header_only;
Expand Down
Loading
Loading