diff --git a/Cargo.lock b/Cargo.lock index b00308f51..1d8ec30c4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1545,8 +1545,12 @@ version = "0.38.0" dependencies = [ "assert_cmd", "expect-test", + "hashbrown 0.17.0", "pyo3", "sqruff-cli-lib", + "sqruff-lib", + "sqruff-lib-core", + "strum", "tempfile", ] diff --git a/crates/cli-python/Cargo.toml b/crates/cli-python/Cargo.toml index e813e3862..e7bde898d 100644 --- a/crates/cli-python/Cargo.toml +++ b/crates/cli-python/Cargo.toml @@ -55,6 +55,10 @@ harness = false [dependencies] sqruff-cli-lib.workspace = true sqruff-cli-lib.features = ["python"] +sqruff-lib.workspace = true +sqruff-lib-core.workspace = true +strum.workspace = true +hashbrown.workspace = true [dev-dependencies] assert_cmd = "2.0.16" diff --git a/crates/cli-python/pyproject.toml b/crates/cli-python/pyproject.toml index 30dd6bcf0..d58016cf0 100644 --- a/crates/cli-python/pyproject.toml +++ b/crates/cli-python/pyproject.toml @@ -26,6 +26,6 @@ build-backend = "maturin" [tool.maturin] manifest-path = "Cargo.toml" -module-name = "sqruff._lib_name" +module-name = "sqruff._sqruff" features = ["pyo3/extension-module"] python-source = "python" diff --git a/crates/cli-python/python/sqruff/__init__.py b/crates/cli-python/python/sqruff/__init__.py index 01fc350d7..26b05ffa5 100644 --- a/crates/cli-python/python/sqruff/__init__.py +++ b/crates/cli-python/python/sqruff/__init__.py @@ -1,4 +1,60 @@ try: - from ._lib_name import run_cli # noqa + from ._sqruff import ( # noqa + run_cli, + Violation, + DialectKind, + RuleGroups, + PlaceholderStyle, + TemplaterKind, + FluffConfig, + LintedFile, + LintingResult, + Linter, + ) except ImportError: pass + + +__all__ = ( + "DialectKind", + "FluffConfig", + "LintedFile", + "Linter", + "LintingResult", + "PlaceholderStyle", + "RuleGroups", + "TemplaterKind", + "Violation", + "fix", + "lint", + "run_cli", +) + + +def lint(sql: str, *, config: "FluffConfig | None" = None) -> "LintedFile": + """Lint (and optionally fix) a SQL string. + + Args: + sql: The SQL string to lint. + config: Optional FluffConfig. If not provided, a default config is used. + + Returns: + A LintedFile. Call .fix_string() for the fixed SQL, .violations for what was found. + """ + if config is None: + config = FluffConfig() + linter = Linter(config) + return linter.lint_string(sql, fix=True) + + +def fix(sql: str, *, config: "FluffConfig | None" = None) -> str: + """Fix (format) a SQL string and return the result. + + Args: + sql: The SQL string to fix. + config: Optional FluffConfig. If not provided, a default config is used. + + Returns: + The fixed SQL string. + """ + return lint(sql, config=config).fix_string() diff --git a/crates/cli-python/python/tests/test_api.py b/crates/cli-python/python/tests/test_api.py new file mode 100644 index 000000000..98e175f4d --- /dev/null +++ b/crates/cli-python/python/tests/test_api.py @@ -0,0 +1,302 @@ +"""Tests for the sqruff Python API. + +Requires the package to be built first: + maturin develop +""" + +import io +import pathlib +import tempfile + +import pytest +import sqruff +from sqruff import ( + DialectKind, + FluffConfig, + LintedFile, + Linter, + PlaceholderStyle, + RuleGroups, + TemplaterKind, + Violation, +) + +SIMPLE_SQL = "select 1" +UNFORMATTED_SQL = "SELECT 1" + + +# ── DialectKind ─────────────────────────────────────────────────────────────── + + +class TestDialectKind: + def test_valid(self): + d = DialectKind("ansi") + assert str(d) == "ansi" + + def test_repr(self): + assert repr(DialectKind("ansi")) == 'DialectKind("ansi")' + + def test_invalid(self): + with pytest.raises(ValueError, match="Unknown dialect"): + DialectKind("notadialect") + + def test_error_lists_options(self): + with pytest.raises(ValueError, match="ansi"): + DialectKind("notadialect") + + def test_available(self): + names = DialectKind.available() + assert "ansi" in names + assert "snowflake" in names + assert len(names) > 5 + + def test_eq(self): + assert DialectKind("ansi") == DialectKind("ansi") + assert DialectKind("ansi") != DialectKind("snowflake") + + def test_hashable(self): + s = {DialectKind("ansi"), DialectKind("snowflake"), DialectKind("ansi")} + assert len(s) == 2 + + +# ── RuleGroups ──────────────────────────────────────────────────────────────── + + +class TestRuleGroups: + def test_valid(self): + g = RuleGroups("core") + assert str(g) == "core" + + def test_repr(self): + assert repr(RuleGroups("core")) == 'RuleGroups("core")' + + def test_invalid(self): + with pytest.raises(ValueError, match="Unknown rule group"): + RuleGroups("notagroup") + + def test_error_lists_options(self): + with pytest.raises(ValueError, match="core"): + RuleGroups("notagroup") + + def test_available(self): + names = RuleGroups.available() + assert "all" in names + assert "core" in names + + def test_hashable(self): + s = {RuleGroups("core"), RuleGroups("layout"), RuleGroups("core")} + assert len(s) == 2 + + +# ── PlaceholderStyle ────────────────────────────────────────────────────────── + + +class TestPlaceholderStyle: + def test_valid(self): + p = PlaceholderStyle("colon") + assert str(p) == "colon" + + def test_invalid(self): + with pytest.raises(ValueError, match="Unknown placeholder style"): + PlaceholderStyle("notastyle") + + def test_error_lists_options(self): + with pytest.raises(ValueError, match="colon"): + PlaceholderStyle("notastyle") + + def test_available(self): + names = PlaceholderStyle.available() + assert "colon" in names + assert len(names) > 1 + + def test_regex_pattern(self): + assert PlaceholderStyle("colon").regex_pattern != "" + + def test_hashable(self): + styles = {PlaceholderStyle(n) for n in PlaceholderStyle.available()} + assert len(styles) == len(PlaceholderStyle.available()) + + +# ── TemplaterKind ───────────────────────────────────────────────────────────── + + +class TestTemplaterKind: + def test_valid(self): + t = TemplaterKind("raw") + assert str(t) == "raw" + + def test_invalid(self): + with pytest.raises(ValueError, match="Unknown templater"): + TemplaterKind("notatemplater") + + def test_error_lists_options(self): + with pytest.raises(ValueError, match="raw"): + TemplaterKind("notatemplater") + + def test_available(self): + names = TemplaterKind.available() + assert "raw" in names + assert "jinja" in names + + def test_hashable(self): + s = {TemplaterKind("raw"), TemplaterKind("jinja"), TemplaterKind("raw")} + assert len(s) == 2 + + +# ── FluffConfig ─────────────────────────────────────────────────────────────── + + +class TestFluffConfig: + def test_default(self): + config = FluffConfig() + assert str(config.dialect) == "ansi" + + def test_dialect_kwarg(self): + config = FluffConfig(dialect="snowflake") + assert str(config.dialect) == "snowflake" + + def test_invalid_dialect_kwarg(self): + with pytest.raises(ValueError, match="Unknown dialect"): + FluffConfig(dialect="notadialect") + + def test_rules_kwarg(self): + # Only LT01 enabled — extra whitespace should trigger it, nothing else + config = FluffConfig(rules=["LT01"]) + linter = Linter(config) + result = linter.lint_string(UNFORMATTED_SQL) + assert result.has_violations + assert all(v.code == "LT01" for v in result.violations) + + def test_exclude_rules_kwarg(self): + # LT01 excluded — extra whitespace should not appear in violations + config = FluffConfig(exclude_rules=["LT01"]) + linter = Linter(config) + result = linter.lint_string(UNFORMATTED_SQL) + assert all(v.code != "LT01" for v in result.violations) + + def test_dialect_setter(self): + config = FluffConfig() + config.dialect = "snowflake" + assert str(config.dialect) == "snowflake" + + def test_dialect_setter_invalid(self): + config = FluffConfig() + with pytest.raises(ValueError): + config.dialect = "notadialect" + + def test_sql_file_exts(self): + config = FluffConfig() + assert ".sql" in config.sql_file_exts + + def test_sql_file_exts_setter(self): + config = FluffConfig() + config.sql_file_exts = [".sql", ".hql"] + assert ".hql" in config.sql_file_exts + + def test_templater(self): + config = FluffConfig() + assert isinstance(config.templater, TemplaterKind) + + def test_from_source(self): + config = FluffConfig.from_source("[sqruff]\ndialect = snowflake\n") + assert str(config.dialect) == "snowflake" + + def test_from_file(self): + f = io.StringIO("[sqruff]\ndialect = snowflake\n") + config = FluffConfig.from_file(f) + assert str(config.dialect) == "snowflake" + + def test_from_path_str(self): + with tempfile.NamedTemporaryFile(mode="w", suffix=".cfg", delete=False) as f: + f.write("[sqruff]\ndialect = snowflake\n") + name = f.name + config = FluffConfig.from_path(name) + assert str(config.dialect) == "snowflake" + + def test_from_path_pathlib(self): + with tempfile.NamedTemporaryFile(mode="w", suffix=".cfg", delete=False) as f: + f.write("[sqruff]\ndialect = snowflake\n") + name = f.name + config = FluffConfig.from_path(pathlib.Path(name)) + assert str(config.dialect) == "snowflake" + + +# ── Linter / LintedFile ─────────────────────────────────────────────────────── + + +class TestLinter: + def test_default_linter(self): + linter = Linter() + result = linter.lint_string(SIMPLE_SQL, filename="test.sql") + assert result.path == "test.sql" + + def test_linter_with_config(self): + config = FluffConfig(dialect="ansi", rules=["LT01"]) + linter = Linter(config) + result = linter.lint_string(UNFORMATTED_SQL) + assert any(v.code == "LT01" for v in result.violations) + + def test_lint_string_returns_linted_file(self): + linter = Linter() + result = linter.lint_string(SIMPLE_SQL) + assert isinstance(result, LintedFile) + + def test_lint_string_no_violations_on_clean_sql(self): + linter = Linter(FluffConfig(rules=[])) + result = linter.lint_string(SIMPLE_SQL) + assert not result.has_violations + + def test_lint_string_violations_on_bad_sql(self): + linter = Linter(FluffConfig(rules=["LT01"])) + result = linter.lint_string(UNFORMATTED_SQL) + assert result.has_violations + assert len(result.violations) > 0 + + def test_violation_fields(self): + linter = Linter(FluffConfig(rules=["LT01"])) + result = linter.lint_string(UNFORMATTED_SQL) + v = result.violations[0] + assert isinstance(v, Violation) + assert isinstance(v.line_no, int) + assert isinstance(v.line_pos, int) + assert isinstance(v.code, str) + assert isinstance(v.description, str) + assert isinstance(v.fixable, bool) + + def test_fix_string(self): + linter = Linter() + result = linter.lint_string(UNFORMATTED_SQL, fix=True) + fixed = result.fix_string() + assert isinstance(fixed, str) + + def test_linted_file_repr(self): + linter = Linter() + result = linter.lint_string(SIMPLE_SQL) + assert "LintedFile" in repr(result) + + +# ── convenience functions ───────────────────────────────────────────────────── + + +class TestConvenienceFunctions: + def test_lint_returns_linted_file(self): + result = sqruff.lint(SIMPLE_SQL) + assert isinstance(result, LintedFile) + + def test_lint_with_config(self): + config = FluffConfig(dialect="ansi") + result = sqruff.lint(SIMPLE_SQL, config=config) + assert isinstance(result, LintedFile) + + def test_fix_returns_string(self): + result = sqruff.fix(SIMPLE_SQL) + assert isinstance(result, str) + + def test_fix_with_config(self): + config = FluffConfig(dialect="ansi") + result = sqruff.fix(SIMPLE_SQL, config=config) + assert isinstance(result, str) + + def test_fix_actually_fixes(self): + fixed = sqruff.fix(UNFORMATTED_SQL) + assert isinstance(fixed, str) diff --git a/crates/cli-python/src/lib.rs b/crates/cli-python/src/lib.rs index 88776ac92..cd482e10c 100644 --- a/crates/cli-python/src/lib.rs +++ b/crates/cli-python/src/lib.rs @@ -1,4 +1,641 @@ +use hashbrown::HashMap; +use std::path::{Path, PathBuf}; +use std::str::FromStr; +use std::sync::Mutex; + use pyo3::prelude::*; +use pyo3::types::{PyDict, PyList}; +use sqruff_lib::core::config::FluffConfig; +use sqruff_lib::core::linter::core::Linter; +use sqruff_lib::core::linter::linted_file::LintedFile; +use sqruff_lib::core::rules::RuleGroups; +use sqruff_lib::templaters::types::PlaceholderStyle; +use sqruff_lib::templaters::TemplaterKind; +use sqruff_lib_core::dialects::init::DialectKind; +use sqruff_lib_core::errors::SQLBaseError; +use sqruff_lib_core::value::Value; +use std::hash::{DefaultHasher, Hash, Hasher}; +use strum::IntoEnumIterator; + +// ── helpers ────────────────────────────────────────────────────────────────── + +fn value_to_py(py: Python, v: &Value) -> Py { + match v { + Value::Int(i) => (*i).into_pyobject(py).unwrap().into_any().unbind(), + Value::Bool(b) => pyo3::types::PyBool::new(py, *b).as_any().clone().unbind(), + Value::Float(f) => (*f).into_pyobject(py).unwrap().into_any().unbind(), + Value::String(s) => s.as_ref().into_pyobject(py).unwrap().into_any().unbind(), + Value::Map(map) => map_to_py_dict(py, map).into_any(), + Value::Array(arr) => { + let items: Vec> = arr.iter().map(|v| value_to_py(py, v)).collect(); + PyList::new(py, items).unwrap().unbind().into_any() + } + Value::None => py.None(), + } +} + +fn map_to_py_dict(py: Python, map: &HashMap) -> Py { + let dict = PyDict::new(py); + for (k, v) in map { + dict.set_item(k, value_to_py(py, v)).unwrap(); + } + dict.unbind() +} + +fn violation_into_py(py: Python, v: &SQLBaseError) -> Py { + Py::new( + py, + Violation { + code: v.rule.as_ref().map(|r| r.code.to_string()).unwrap_or_default(), + line_no: v.line_no, + line_pos: v.line_pos, + description: v.description.clone(), + fixable: v.fixable, + }, + ) + .unwrap() +} + + + +// ── Violation ──────────────────────────────────────────────────────────────── + +/// A single linting violation. +#[pyclass(name = "Violation")] +struct Violation { + #[pyo3(get)] + code: String, + #[pyo3(get)] + line_no: usize, + #[pyo3(get)] + line_pos: usize, + #[pyo3(get)] + description: String, + #[pyo3(get)] + fixable: bool, +} + +#[pymethods] +impl Violation { + fn __repr__(&self) -> String { + format!( + "Violation(code={:?}, line_no={}, line_pos={}, fixable={}, description={:?})", + self.code, self.line_no, self.line_pos, self.fixable, self.description + ) + } +} + +// ── DialectKind ────────────────────────────────────────────────────────────── + +/// Represents a SQL dialect. Construct with `DialectKind("snowflake")` or use +/// `DialectKind.available()` to list all supported dialects. +#[pyclass(name = "DialectKind", from_py_object)] +#[derive(Clone)] +struct PyDialectKind(DialectKind); + +#[pymethods] +impl PyDialectKind { + #[new] + fn new(dialect: &str) -> PyResult { + DialectKind::from_str(dialect).map(PyDialectKind).map_err(|_| { + let valid = Self::available().join(", "); + PyErr::new::(format!( + "Unknown dialect: {dialect:?}. Valid dialects are: {valid}" + )) + }) + } + + fn __repr__(&self) -> String { + format!("DialectKind({:?})", self.0.as_ref()) + } + + fn __str__(&self) -> &str { + self.0.as_ref() + } + + fn __eq__(&self, other: &Self) -> bool { + self.0 == other.0 + } + + fn __hash__(&self) -> u64 { + let mut s = DefaultHasher::new(); + self.0.as_ref().hash(&mut s); + s.finish() + } + + /// List all available dialect names. + #[staticmethod] + fn available() -> Vec { + DialectKind::iter().map(|d| d.as_ref().to_string()).collect() + } +} + +// ── RuleGroups ──────────────────────────────────────────────────────────────── + +/// A rule category. Use `RuleGroups.available()` to list all groups. +#[pyclass(name = "RuleGroups", from_py_object)] +#[derive(Clone)] +struct PyRuleGroups(RuleGroups); + +#[pymethods] +impl PyRuleGroups { + #[new] + fn new(group: &str) -> PyResult { + RuleGroups::iter() + .find(|g| g.as_ref() == group) + .map(PyRuleGroups) + .ok_or_else(|| { + let valid = Self::available().join(", "); + PyErr::new::(format!( + "Unknown rule group: {group:?}. Valid groups are: {valid}" + )) + }) + } + + fn __str__(&self) -> &str { + self.0.as_ref() + } + + fn __repr__(&self) -> String { + format!("RuleGroups({:?})", self.0.as_ref()) + } + + fn __eq__(&self, other: &Self) -> bool { + self.0 == other.0 + } + + fn __hash__(&self) -> u64 { + let mut s = DefaultHasher::new(); + self.0.as_ref().hash(&mut s); + s.finish() + } + + /// List all available rule group names. + #[staticmethod] + fn available() -> Vec { + RuleGroups::iter().map(|g| g.as_ref().to_string()).collect() + } +} + +// ── PlaceholderStyle ────────────────────────────────────────────────────────── + +/// A placeholder templater syntax style (e.g. `:var`, `$var`, `%(var)s`). +/// Use `PlaceholderStyle.available()` to list all styles. +#[pyclass(name = "PlaceholderStyle", from_py_object)] +#[derive(Clone)] +struct PyPlaceholderStyle(PlaceholderStyle); + +#[pymethods] +impl PyPlaceholderStyle { + #[new] + fn new(style: &str) -> PyResult { + PlaceholderStyle::from_name(style) + .map(PyPlaceholderStyle) + .map_err(|_| { + let valid = Self::available().join(", "); + PyErr::new::(format!( + "Unknown placeholder style: {style:?}. Valid styles are: {valid}" + )) + }) + } + + fn __str__(&self) -> &str { + self.0.as_str() + } + + fn __repr__(&self) -> String { + format!("PlaceholderStyle({:?})", self.0.as_str()) + } + + fn __eq__(&self, other: &Self) -> bool { + self.0 == other.0 + } + + fn __hash__(&self) -> u64 { + let mut s = DefaultHasher::new(); + self.0.as_str().hash(&mut s); + s.finish() + } + + /// The regex pattern used to match placeholders of this style. + #[getter] + fn regex_pattern(&self) -> &str { + self.0.regex_pattern() + } + + /// List all available placeholder style names. + #[staticmethod] + fn available() -> Vec<&'static str> { + PlaceholderStyle::all().iter().map(|s| s.as_str()).collect() + } +} + +// ── TemplaterKind ───────────────────────────────────────────────────────────── + +/// A templater engine (e.g. "raw", "jinja", "dbt"). Use `TemplaterKind.available()` to list all. +#[pyclass(name = "TemplaterKind", from_py_object)] +#[derive(Clone)] +struct PyTemplaterKind(TemplaterKind); + +#[pymethods] +impl PyTemplaterKind { + #[new] + fn new(templater: &str) -> PyResult { + TemplaterKind::from_name(templater) + .map(PyTemplaterKind) + .map_err(|_| { + let valid = Self::available().join(", "); + PyErr::new::(format!( + "Unknown templater: {templater:?}. Valid templaters are: {valid}" + )) + }) + } + + fn __str__(&self) -> &str { + self.0.as_str() + } + + fn __repr__(&self) -> String { + format!("TemplaterKind({:?})", self.0.as_str()) + } + + fn __eq__(&self, other: &Self) -> bool { + self.0 == other.0 + } + + fn __hash__(&self) -> u64 { + let mut s = DefaultHasher::new(); + self.0.as_str().hash(&mut s); + s.finish() + } + + /// List all available templater names. + #[staticmethod] + fn available() -> Vec<&'static str> { + TemplaterKind::available_names() + } +} + +// ── FluffConfig ─────────────────────────────────────────────────────────────── + +/// Linter configuration. +/// +/// Can be constructed several ways: +/// FluffConfig() # defaults (dialect: ansi) +/// FluffConfig(dialect="snowflake") # shorthand kwargs +/// FluffConfig(rules=["LT01"], exclude_rules=["AM01"]) +/// FluffConfig.from_source("[sqruff]\\ndialect = snowflake\\n") +/// FluffConfig.from_file(open(".sqruff")) +/// FluffConfig.from_path("/path/to/.sqruff") # str or pathlib.Path +/// FluffConfig.from_root() # walk up from cwd +#[pyclass(name = "FluffConfig", from_py_object)] +#[derive(Clone)] +struct PyFluffConfig(FluffConfig); + +#[pymethods] +impl PyFluffConfig { + /// Create a config, optionally setting common options via keyword arguments. + #[new] + #[pyo3(signature = (dialect=None, rules=None, exclude_rules=None))] + fn new( + dialect: Option<&str>, + rules: Option>, + exclude_rules: Option>, + ) -> PyResult { + let mut core = HashMap::::new(); + if let Some(d) = dialect { + PyDialectKind::new(d)?; + core.insert("dialect".into(), Value::String(d.into())); + } + if let Some(r) = rules { + core.insert("rules".into(), Value::String(r.join(",").into())); + } + if let Some(er) = exclude_rules { + core.insert("exclude_rules".into(), Value::String(er.join(",").into())); + } + let mut configs = HashMap::new(); + configs.insert("core".into(), Value::Map(core)); + Ok(PyFluffConfig(FluffConfig::new(configs, None, None))) + } + + /// Create a config from an INI-format string. + /// + /// Example: + /// FluffConfig.from_source("[sqruff]\\ndialect = snowflake\\n") + #[staticmethod] + #[pyo3(signature = (source, path = None))] + fn from_source(source: &str, path: Option<&str>) -> Self { + PyFluffConfig(FluffConfig::from_source(source, path.map(Path::new))) + } + + /// Create a config by reading from a file-like object (anything with a `.read()` method). + /// + /// Example: + /// with open(".sqruff") as f: + /// config = FluffConfig.from_file(f) + #[staticmethod] + fn from_file(file: &Bound) -> PyResult { + let content: String = file.call_method0("read")?.extract()?; + let path = file.getattr("name").ok().and_then(|n| n.extract::().ok()); + Ok(PyFluffConfig(FluffConfig::from_source(&content, path.as_deref().map(Path::new)))) + } + + /// Create a config by loading a `.sqruff` file at the given path (str or pathlib.Path). + #[staticmethod] + fn from_path(path: PathBuf) -> Self { + PyFluffConfig(FluffConfig::from_file(&path)) + } + + /// Load config by walking up from a directory, merging any `.sqruff` files found. + /// + /// Args: + /// extra_config_path: An additional config file to load. + /// ignore_local_config: If True, skip any `.sqruff` files on disk. + /// overrides: Dict of key→value overrides applied on top (e.g. `{"dialect": "snowflake"}`). + #[staticmethod] + #[pyo3(signature = (extra_config_path=None, ignore_local_config=false, overrides=None))] + fn from_root( + extra_config_path: Option, + ignore_local_config: bool, + overrides: Option>, + ) -> PyResult { + let overrides = overrides.map(|m| m.into_iter().collect::>()); + FluffConfig::from_root(extra_config_path, ignore_local_config, overrides) + .map(PyFluffConfig) + .map_err(|e| PyErr::new::(e.value)) + } + + /// The currently configured dialect. + #[getter] + fn dialect(&self) -> PyDialectKind { + PyDialectKind(self.0.dialect_kind()) + } + + #[setter] + fn set_dialect(&mut self, dialect: &str) -> PyResult<()> { + self.0 + .override_dialect(PyDialectKind::new(dialect)?.0) + .map_err(|e| PyErr::new::(e)) + } + + /// The currently configured templater. + #[getter] + fn templater(&self) -> PyResult { + self.0 + .templater_kind() + .map(PyTemplaterKind) + .map_err(|e| PyErr::new::(e)) + } + + /// Get a single config value by key and section name. + fn get(&self, py: Python, key: &str, section: &str) -> Py { + value_to_py(py, self.0.get(key, section)) + } + + /// Get an entire config section as a dict. + fn get_section(&self, py: Python, section: &str) -> Py { + map_to_py_dict(py, self.0.get_section(section)) + } + + /// File extensions that sqruff will consider as SQL files. + #[getter] + fn sql_file_exts(&self) -> Vec { + self.0.sql_file_exts().to_vec() + } + + #[setter] + fn set_sql_file_exts(&mut self, exts: Vec) { + self.0 = std::mem::take(&mut self.0).with_sql_file_exts(exts); + } + + /// Raise a ValueError if no dialect has been configured. + fn verify_dialect_specified(&self) -> PyResult<()> { + match self.0.verify_dialect_specified() { + None => Ok(()), + Some(e) => Err(PyErr::new::(e.value)), + } + } + + /// Get a value from the templater's root config section. + fn get_templater_root_value(&self, py: Python, key: &str) -> Py { + match self.0.templater_root_value(key) { + Some(v) => value_to_py(py, v), + None => py.None(), + } + } + + /// Get the full config section for a given templater as a dict, or None. + fn get_templater_section(&self, py: Python, templater: &str) -> PyResult> { + let kind = PyTemplaterKind::new(templater)?.0; + Ok(match self.0.templater_section(kind) { + Some(map) => map_to_py_dict(py, map).into_any(), + None => py.None(), + }) + } + + /// Get a single value from a templater's config section, or None. + fn get_templater_value(&self, py: Python, templater: &str, key: &str) -> PyResult> { + let kind = PyTemplaterKind::new(templater)?.0; + Ok(match self.0.templater_value(kind, key) { + Some(v) => value_to_py(py, v), + None => py.None(), + }) + } + + /// Get the context dict for a templater, or None. + fn get_templater_context(&self, py: Python, templater: &str) -> PyResult> { + let kind = PyTemplaterKind::new(templater)?.0; + Ok(match self.0.templater_context(kind) { + Some(map) => map_to_py_dict(py, map).into_any(), + None => py.None(), + }) + } + + /// Recompute the reflow config from the current raw config. + fn reload_reflow(&mut self) { + self.0.reload_reflow(); + } +} + +// ── LintedFile ──────────────────────────────────────────────────────────────── + +/// The result of linting a single file or string. +#[pyclass(name = "LintedFile")] +struct PyLintedFile { + inner: LintedFile, +} + +#[pymethods] +impl PyLintedFile { + /// The file path (or `""` when linting a string). + #[getter] + fn path(&self) -> &str { + self.inner.path() + } + + /// List of all violations found. + #[getter] + fn violations(&self, py: Python) -> Vec> { + self.inner.violations().iter().map(|v| violation_into_py(py, v)).collect() + } + + #[getter] + fn has_violations(&self) -> bool { + self.inner.has_violations() + } + + #[getter] + fn has_unfixable_violations(&self) -> bool { + self.inner.has_unfixable_violations() + } + + #[getter] + fn has_fixes(&self) -> bool { + self.inner.has_fixes() + } + + /// Return the fixed SQL string. Only meaningful when `fix=True` was passed to + /// `lint_string` / `lint_paths`. + fn fix_string(&self) -> String { + self.inner.clone().fix_string() + } + + fn __repr__(&self) -> String { + format!( + "LintedFile(path={:?}, violations={})", + self.inner.path(), + self.inner.violations().len() + ) + } +} + +// ── LintingResult ───────────────────────────────────────────────────────────── + +/// The result of linting multiple files. +#[pyclass(name = "LintingResult")] +struct PyLintingResult { + files: Vec>, + has_violations: bool, + has_unfixable_violations: bool, +} + +#[pymethods] +impl PyLintingResult { + /// Iterate over the individual `LintedFile` results. + fn __iter__(&self, py: Python) -> PyResult> { + let list = PyList::new(py, &self.files)?; + Ok(list.call_method0("__iter__")?.unbind()) + } + + fn __len__(&self) -> usize { + self.files.len() + } + + #[getter] + fn has_violations(&self) -> bool { + self.has_violations + } + + #[getter] + fn has_unfixable_violations(&self) -> bool { + self.has_unfixable_violations + } + + fn __repr__(&self) -> String { + format!("LintingResult(files={})", self.files.len()) + } +} + +// ── Linter ──────────────────────────────────────────────────────────────────── + +/// The main linting engine. +/// +/// Example: +/// linter = Linter() +/// result = linter.lint_string("select 1", fix=True) +/// print(result.fix_string()) +#[pyclass(name = "Linter")] +struct PyLinter { + // Mutex because lint_paths takes &mut self + inner: Mutex, +} + +#[pymethods] +impl PyLinter { + /// Create a Linter. Accepts an optional `FluffConfig`; defaults are used otherwise. + #[new] + #[pyo3(signature = (config = None, include_parse_errors = false))] + fn new(config: Option, include_parse_errors: bool) -> PyResult { + let config = config.map(|c| c.0).unwrap_or_default(); + let linter = Linter::new(config, None, None, include_parse_errors) + .map_err(|e| PyErr::new::(e))?; + Ok(PyLinter { inner: Mutex::new(linter) }) + } + + /// Lint (and optionally fix) a SQL string. + /// + /// Args: + /// sql: The SQL to lint. + /// filename: Optional filename for error messages. + /// fix: If True, apply automatic fixes. + /// + /// Returns: + /// A LintedFile. + #[pyo3(signature = (sql, filename = None, fix = false))] + fn lint_string( + &self, + py: Python, + sql: &str, + filename: Option, + fix: bool, + ) -> PyResult> { + let linter = self.inner.lock().unwrap(); + let linted = linter + .lint_string(sql, filename, fix) + .map_err(|e| PyErr::new::(e.to_string()))?; + Py::new(py, PyLintedFile { inner: linted }) + } + + /// Lint (and optionally fix) a list of file paths. + /// + /// Args: + /// paths: File or directory paths to lint. + /// fix: If True, compute fixes (does NOT write files; call fix_string() on each + /// result and write yourself). + /// + /// Returns: + /// A LintingResult iterable of LintedFile objects. + #[pyo3(signature = (paths, fix = false))] + fn lint_paths( + &self, + py: Python, + paths: Vec, + fix: bool, + ) -> PyResult> { + let mut linter = self.inner.lock().unwrap(); + let result = linter + .lint_paths( + paths.into_iter().map(PathBuf::from).collect(), + fix, + &|_: &Path| false, + ) + .map_err(|e| PyErr::new::(e.to_string()))?; + + let has_violations = result.has_violations(); + let has_unfixable = result.has_unfixable_violations(); + let files: Vec> = result + .into_iter() + .map(|f| Py::new(py, PyLintedFile { inner: f }).unwrap()) + .collect(); + + Py::new( + py, + PyLintingResult { files, has_violations, has_unfixable_violations: has_unfixable }, + ) + } +} + +// ── run_cli ─────────────────────────────────────────────────────────────────── /// Parse CLI args and execute the tool. Exposed to Python as `run_cli`. #[pyfunction] @@ -9,9 +646,20 @@ fn run_cli(args: Vec) -> PyResult { Ok(exit_code) } +// ── module ──────────────────────────────────────────────────────────────────── + #[pymodule] -#[pyo3(name = "_lib_name")] +#[pyo3(name = "_sqruff")] fn sqruff(_py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(run_cli, m)?)?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; Ok(()) } diff --git a/crates/lib/src/core/rules.rs b/crates/lib/src/core/rules.rs index 484cde8fc..af712541e 100644 --- a/crates/lib/src/core/rules.rs +++ b/crates/lib/src/core/rules.rs @@ -17,7 +17,7 @@ use sqruff_lib_core::helpers::{Config, IndexMap}; use sqruff_lib_core::lint_fix::LintFix; use sqruff_lib_core::parser::segments::{ErasedSegment, Tables}; use sqruff_lib_core::templaters::TemplatedFile; -use strum_macros::AsRefStr; +use strum_macros::{AsRefStr, EnumIter}; use crate::core::config::{FluffConfig, Value}; use crate::core::rules::context::RuleContext; @@ -30,7 +30,7 @@ pub struct LintResult { source: String, } -#[derive(Debug, Clone, PartialEq, Copy, Hash, Eq, AsRefStr)] +#[derive(Debug, Clone, PartialEq, Copy, Hash, Eq, AsRefStr, EnumIter)] #[strum(serialize_all = "lowercase")] pub enum RuleGroups { All,