sqruff-lib 0.39.0

A high-speed SQL linter.
Documentation
use hashbrown::HashMap;
use sqruff_lib_core::dialects::syntax::SyntaxKind;
use sqruff_lib_core::lint_fix::LintFix;
use sqruff_lib_core::parser::segments::ErasedSegment;
use sqruff_lib_core::templaters::TemplatedFile;

use crate::core::config::Value;
use crate::core::rules::context::RuleContext;
use crate::core::rules::crawlers::{Crawler, RootOnlyCrawler};
use crate::core::rules::{Erased, ErasedRule, LintResult, Rule, RuleGroups};
use crate::utils::reflow::sequence::ReflowSequence;

#[derive(Default, Debug, Clone)]
pub struct RuleLT02;

fn line_bounds(source: &str, pos: usize) -> (usize, usize) {
    let pos = pos.min(source.len());
    let start = source[..pos].rfind('\n').map_or(0, |idx| idx + 1);
    let end = source[pos..]
        .find('\n')
        .map_or(source.len(), |idx| pos + idx);
    (start, end)
}

fn skip_whitespace_forward(source: &str, mut pos: usize) -> usize {
    while pos < source.len() {
        let Some(ch) = source[pos..].chars().next() else {
            break;
        };
        if !ch.is_whitespace() {
            break;
        }
        pos += ch.len_utf8();
    }
    pos
}

fn skip_whitespace_backward(source: &str, mut pos: usize) -> usize {
    while pos > 0 {
        let Some(ch) = source[..pos].chars().next_back() else {
            break;
        };
        if !ch.is_whitespace() {
            break;
        }
        pos -= ch.len_utf8();
    }
    pos
}

fn source_only_slice_at(templated_file: &TemplatedFile, pos: usize) -> bool {
    templated_file.raw_sliced().iter().any(|slice| {
        slice.slice_kind().is_source_only()
            && slice.source_slice().start <= pos
            && pos < slice.source_slice().end
    }) || templated_file.sliced_file.iter().any(|slice| {
        slice.templated_slice.is_empty()
            && slice.source_slice.start <= pos
            && pos < slice.source_slice.end
    })
}

fn line_is_adjacent_to_source_only_slice(templated_file: &TemplatedFile, pos: usize) -> bool {
    let source = templated_file.source_str.as_str();
    let (line_start, line_end) = line_bounds(source, pos);

    let before = skip_whitespace_backward(source, line_start);
    let after = skip_whitespace_forward(source, line_end);

    source_only_slice_at(templated_file, line_start)
        || source_only_slice_at(templated_file, pos)
        || (before > 0 && source_only_slice_at(templated_file, before - 1))
        || source_only_slice_at(templated_file, after)
}

fn source_line_has_non_source_only_non_whitespace(
    templated_file: &TemplatedFile,
    pos: usize,
) -> bool {
    let source = templated_file.source_str.as_str();
    let (line_start, line_end) = line_bounds(source, pos);
    source[line_start..line_end]
        .char_indices()
        .any(|(idx, ch)| {
            !ch.is_whitespace() && !source_only_slice_at(templated_file, line_start + idx)
        })
}

fn is_literal_whitespace_segment(segment: &ErasedSegment) -> bool {
    (segment.is_type(SyntaxKind::Newline) || segment.is_type(SyntaxKind::Whitespace))
        && segment
            .get_position_marker()
            .is_some_and(|marker| marker.is_literal())
}

fn is_whitespace_edit(segment: &ErasedSegment) -> bool {
    segment.is_type(SyntaxKind::Newline) || segment.is_type(SyntaxKind::Whitespace)
}

fn is_literal_indentation_fix(fix: &LintFix) -> bool {
    match fix {
        LintFix::Replace { anchor, edit, .. } => {
            is_literal_whitespace_segment(anchor) && edit.iter().all(is_whitespace_edit)
        }
        _ => false,
    }
}

fn has_only_literal_indentation_fixes(result: &LintResult) -> bool {
    !result.fixes.is_empty() && result.fixes.iter().all(is_literal_indentation_fix)
}

impl Rule for RuleLT02 {
    fn load_from_config(&self, _config: &HashMap<String, Value>) -> Result<ErasedRule, String> {
        Ok(RuleLT02.erased())
    }
    fn name(&self) -> &'static str {
        "layout.indent"
    }

    fn description(&self) -> &'static str {
        "Incorrect Indentation."
    }

    fn long_description(&self) -> &'static str {
        r#"
**Anti-pattern**

The ``•`` character represents a space and the ``→`` character represents a tab.
In this example, the third line contains five spaces instead of four and
the second line contains two spaces and one tab.

```sql
SELECT
••→a,
•••••b
FROM foo
```

**Best practice**

Change the indentation to use a multiple of four spaces. This example also assumes that the indent_unit config value is set to space. If it had instead been set to tab, then the indents would be tabs instead.

```sql
SELECT
••••a,
••••b
FROM foo
```
"#
    }

    fn groups(&self) -> &'static [RuleGroups] {
        &[RuleGroups::All, RuleGroups::Core, RuleGroups::Layout]
    }

    fn eval(&self, context: &RuleContext) -> Vec<LintResult> {
        let results = ReflowSequence::from_root(&context.segment, context.config)
            .reindent(context.tables)
            .results();

        let Some(templated_file) = &context.templated_file else {
            return results;
        };

        results
            .into_iter()
            .filter(|result| {
                !result.anchor.as_ref().is_some_and(|anchor| {
                    anchor.get_position_marker().is_some_and(|marker| {
                        let source_pos = marker.source_slice.start;
                        line_is_adjacent_to_source_only_slice(templated_file, source_pos)
                            && (!has_only_literal_indentation_fixes(result)
                                || !source_line_has_non_source_only_non_whitespace(
                                    templated_file,
                                    source_pos,
                                ))
                    })
                })
            })
            .collect()
    }

    fn is_fix_compatible(&self) -> bool {
        true
    }

    fn crawl_behaviour(&self) -> Crawler {
        RootOnlyCrawler.into()
    }
}