Skip to content
← Back to Examples

Code Linter with Slice Positions

Rustconceptualadvanced

Build a code linter that uses slice positions to report exact source locations for issues.

Key Concepts

  • source position tracking
  • line/column conversion
  • diagnostic reporting
  • AST walking
  • lint rule implementation

Why Parse This Format?

Linters need to report exact source locations for issues. Parsanol's slice position tracking makes this straightforward.

Use Cases

  • Building custom linters for DSLs
  • Configuration file validation
  • IDE diagnostic integration
  • Code review automation

Input Format

Simple key=value configuration files

key = value (one per line, # for comments)

Examples

debug = yes database-host = localhost path = /home/user/dataVALID

Configuration with lint issues

# Good config server_port = 8080 enable_ssl = trueVALID

Configuration following best practices

Output Format

List of lint issues with positions and suggestions

Structure

severity

Error, Warning, Info, or Hint

code

Lint rule code (e.g., W001)

message

Human-readable description

position

SourcePosition with line, column, offset

suggestion

Optional fix suggestion

How the Code Works

This section explains how the parsing rules map to the format semantics.

1. Source Position Tracking

use parsanol::portable::{SourcePosition, offset_to_line_col};

// Convert byte offset to line:column
let pos = offset_to_line_col(input, offset);
// pos.line, pos.column, pos.offset are all available

The linter uses `SourcePosition` to track exactly where issues occur:

2. LintIssue Structure

pub struct LintIssue {
    pub severity: Severity,      // Error, Warning, Info, Hint
    pub code: String,            // e.g., "W001", "E001"
    pub message: String,         // Human-readable description
    pub position: SourcePosition, // Exact location
    pub suggestion: Option<String>, // How to fix it
}

Each lint issue contains full context for reporting:

3. Extracting Slice Positions from AST

match node {
    AstNode::InputRef { offset, length } => {
        // Get the actual text
        let text = &input[*offset as usize..(*offset + *length) as usize];

        // Get line/column position
        let pos = offset_to_line_col(input, *offset as usize);

        // Now we can report issues at this exact position
    }
    // ... other node types
}

When walking the parsed AST, we can extract the exact position of each element:

4. Pattern Matching for Lint Checks

The linter demonstrates several common lint patterns: - **Naming Convention Check**: Detecting hyphens in keys - **Length Checks**: Warning about very short identifiers - **Value Validation**: Checking for trailing whitespace, boolean consistency - **Portability Issues**: Detecting hardcoded paths

4. Pattern Matching for Lint Checks

The linter demonstrates several common lint patterns: - **Naming Convention Check**: Detecting hyphens in keys - **Length Checks**: Warning about very short identifiers - **Value Validation**: Checking for trailing whitespace, boolean consistency - **Portability Issues**: Detecting hardcoded paths

Source Code

Rust
//! Code Linter Example
//!
//! Demonstrates using slice positions for linting and code analysis.
//! Shows how to track exact source locations for reporting issues.
//!
//! Run with: cargo run --example linter --no-default-features

#![allow(clippy::print_literal)]

use parsanol::portable::{
    offset_to_line_col,
    parser_dsl::{re, GrammarBuilder},
    AstArena, Grammar, PortableParser,
};

/// A lint issue found in the code
#[derive(Debug, Clone)]
pub struct LintIssue {
    /// Issue severity
    pub severity: Severity,
    /// Issue code (e.g., "E001", "W001")
    pub code: String,
    /// Human-readable message
    pub message: String,
    /// Line number (1-based)
    pub line: usize,
    /// Column number (1-based)
    pub column: usize,
    /// Byte offset in input
    pub offset: usize,
    /// Optional suggestion for fixing
    pub suggestion: Option<String>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Severity {
    Error,
    Warning,
    Info,
    Hint,
}

impl std::fmt::Display for Severity {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Severity::Error => write!(f, "error"),
            Severity::Warning => write!(f, "warning"),
            Severity::Info => write!(f, "info"),
            Severity::Hint => write!(f, "hint"),
        }
    }
}

/// Reserved words that cannot be used as keys
const RESERVED_WORDS: &[&str] = &[
    "if", "else", "while", "for", "return", "fn", "let", "const", "true", "false", "null", "break",
    "continue", "import", "export",
];

/// Build a simple configuration grammar
/// Note: The grammar just validates that input is text. Real validation
/// happens in the linting logic using slice positions.
fn build_config_grammar() -> Grammar {
    GrammarBuilder::new()
        // Match any text including newlines (config files are just text)
        .rule("config", re("(?s).*"))
        .build()
}

/// Lint a configuration file and return issues
fn lint_config(input: &str) -> Result<Vec<LintIssue>, String> {
    // First, parse to validate syntax
    let grammar = build_config_grammar();
    let mut arena = AstArena::for_input(input.len());
    let mut parser = PortableParser::new(&grammar, input, &mut arena);
    let _ast = parser
        .parse()
        .map_err(|e| format!("Parse error: {:?}", e))?;

    let mut issues = Vec::new();

    // Process each line for semantic issues
    for (line_idx, line) in input.lines().enumerate() {
        let line_start: usize = input.lines().take(line_idx).map(|l| l.len() + 1).sum();
        let trimmed = line.trim();

        // Skip empty lines and comments
        if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with(';') {
            continue;
        }

        // Check for key-value pairs
        if let Some(eq_pos) = line.find('=') {
            let key = line[..eq_pos].trim();
            let value = line[eq_pos + 1..].trim();

            let key_offset = line_start + line.find(key).unwrap_or(0);
            let value_offset = line_start
                + eq_pos
                + 1
                + line[eq_pos + 1..]
                    .find(value.chars().next().unwrap_or(' '))
                    .unwrap_or(0);

            let (key_line, key_col) = offset_to_line_col(input, key_offset);
            let (val_line, val_col) = offset_to_line_col(input, value_offset);

            // Check 1: Reserved word as key
            if RESERVED_WORDS.contains(&key) {
                issues.push(LintIssue {
                    severity: Severity::Error,
                    code: "E001".to_string(),
                    message: format!("'{}' is a reserved word", key),
                    line: key_line,
                    column: key_col,
                    offset: key_offset,
                    suggestion: Some(format!("Rename to '{}_' or 'my_{}'", key, key)),
                });
            }

            // Check 2: Hyphens in key (prefer underscores)
            if key.contains('-') {
                issues.push(LintIssue {
                    severity: Severity::Warning,
                    code: "W001".to_string(),
                    message: format!("Key '{}' uses hyphens; prefer underscores", key),
                    line: key_line,
                    column: key_col,
                    offset: key_offset,
                    suggestion: Some(key.replace('-', "_")),
                });
            }

            // Check 3: Very short key (likely typo)
            if key.len() == 1 {
                issues.push(LintIssue {
                    severity: Severity::Info,
                    code: "I001".to_string(),
                    message: format!("Key '{}' is very short; check for typos", key),
                    line: key_line,
                    column: key_col,
                    offset: key_offset,
                    suggestion: None,
                });
            }

            // Check 4: Boolean value consistency
            let lower_value = value.to_lowercase();
            if lower_value == "yes" || lower_value == "no" {
                issues.push(LintIssue {
                    severity: Severity::Hint,
                    code: "H001".to_string(),
                    message: format!("Boolean '{}' is non-standard", value),
                    line: val_line,
                    column: val_col,
                    offset: value_offset,
                    suggestion: Some("Use 'true' or 'false'".to_string()),
                });
            }

            // Check 5: Port number range
            if key.contains("port") {
                if let Ok(port) = value.parse::<u16>() {
                    if port < 1024 {
                        issues.push(LintIssue {
                            severity: Severity::Warning,
                            code: "W002".to_string(),
                            message: format!("Port {} is a privileged port (< 1024)", port),
                            line: val_line,
                            column: val_col,
                            offset: value_offset,
                            suggestion: Some("Use a port >= 1024".to_string()),
                        });
                    }
                }
            }

            // Check 6: Hardcoded paths
            if value.starts_with("/home/") || value.starts_with("C:\\") {
                issues.push(LintIssue {
                    severity: Severity::Warning,
                    code: "W003".to_string(),
                    message: "Hardcoded path detected; may not be portable".to_string(),
                    line: val_line,
                    column: val_col,
                    offset: value_offset,
                    suggestion: Some("Use environment variables".to_string()),
                });
            }

            // Check 7: Leading/trailing whitespace in value
            if value.starts_with(' ') || value.ends_with(' ') {
                issues.push(LintIssue {
                    severity: Severity::Hint,
                    code: "H002".to_string(),
                    message: "Value has leading/trailing whitespace".to_string(),
                    line: val_line,
                    column: val_col,
                    offset: value_offset,
                    suggestion: Some("Trim or quote the value".to_string()),
                });
            }
        }
    }

    // Sort by offset
    issues.sort_by_key(|i| i.offset);

    Ok(issues)
}

/// Format lint issues for display
fn format_issues(issues: &[LintIssue], input: &str) -> String {
    let mut output = String::new();

    for issue in issues {
        // Get the line content
        let line_start = input[..issue.offset]
            .rfind('\n')
            .map(|n| n + 1)
            .unwrap_or(0);
        let line_end = input[issue.offset..]
            .find('\n')
            .map(|n| issue.offset + n)
            .unwrap_or(input.len());
        let line = &input[line_start..line_end];

        // Format: severity[code]: message at line:col
        output.push_str(&format!(
            "{}[{}]: {} at {}:{}\n",
            issue.severity, issue.code, issue.message, issue.line, issue.column
        ));

        // Show the line with caret
        output.push_str(&format!("  {}\n", line));
        output.push_str(&format!(
            "  {}^\n",
            " ".repeat(issue.column.saturating_sub(1))
        ));

        // Show suggestion if available
        if let Some(ref suggestion) = issue.suggestion {
            output.push_str(&format!("  Suggestion: {}\n", suggestion));
        }

        output.push('\n');
    }

    output
}

fn main() {
    let input = r#"# Configuration file with various issues
debug = yes
database-host = localhost
db_port = 5432
return = something
path = /home/user/data
admin_email = invalid-email
x =  value with spaces
privileged_port = 80
"#;

    println!("Code Linter Example");
    println!("{}\n", "=".repeat(60));

    println!("Input:");
    println!("{}\n", input);
    println!("{}", "-".repeat(60));

    match lint_config(input) {
        Ok(issues) => {
            if issues.is_empty() {
                println!("No issues found!");
            } else {
                println!("Found {} issue(s):\n", issues.len());
                println!("{}", format_issues(&issues, input));
            }
        }
        Err(e) => {
            eprintln!("Error: {}", e);
        }
    }

    // Demonstrate position tracking
    println!("{}", "-".repeat(60));
    println!("Position Tracking Demo:");

    let test_input = "hello world\nsecond line\nthird line";
    for offset in [0, 6, 12, 23] {
        let (line, col) = offset_to_line_col(test_input, offset);
        println!("  offset {} -> line {}, column {}", offset, line, col);
    }
}