Skip to content
Merged
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
88 changes: 66 additions & 22 deletions crates/mq-lint/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,21 @@ for d in diagnostics {

Each diagnostic's `rule_id()` and `message()` are derived from `d.kind`, a [`LintMessage`](src/message.rs) enum with one variant per rule. Rule identity (`RuleId`) and message text are both enums rather than free-form strings, so adding or renaming a rule is a compile-time-checked change in one place (`src/message.rs`).

### Applying Fixes

Some rules (marked "Fixable" in the tables below) attach a [`Fix`](src/fix.rs) that a caller can resolve against the original source and apply:

```rust
let code = "s\"${x}\"";
let edits: Vec<_> = diagnostics
.iter()
.filter_map(|d| d.fix.as_ref().and_then(|fix| fix.resolve(code)))
.collect();
let fixed = mq_lint::fix::apply_edits(code, &edits);
```

A `Fix` only records source ranges (not text), so it stays independent of any one source string until `resolve` is called — the same diagnostics can be resolved against the CLI's file contents or the language server's in-editor buffer.

### As a CLI

Build with the `cli` feature to get the `mq-lint` binary (also invocable as `mq lint` if placed on your `PATH`, see [External Subcommands](https://mqlang.org/book/start/external_subcommands.html)):
Expand All @@ -47,9 +62,13 @@ echo "let x = .h1" | mq-lint # read from stdin
mq-lint --disable naming_convention script.mq
mq-lint --min-severity warn script.mq # only show warn/error diagnostics
mq-lint --list-rules # print all rule IDs and their severity
mq-lint --fix script.mq # rewrite the file, applying every fixable diagnostic
echo "let x = .h1" | mq-lint --fix # write the fixed code to stdout
```

Exits with a non-zero status if any diagnostic (at or above `--min-severity`) was reported.
Exits with a non-zero status if any diagnostic (at or above `--min-severity`) was reported. `--fix` only
rewrites diagnostics that have a machine-applicable fix (see "Fixable" in the rules tables below); it then
reports any remaining diagnostics as usual.

### Disabling Rules

Expand All @@ -76,17 +95,20 @@ config.complexity.max_interpolation_exprs = 4;

### Correctness

| Rule ID | Severity | Description |
| ----------------------- | -------- | ------------------------------------------------------------------------------ |
| `unused_variable` | warn | `let`/`var` variable declared but never referenced |
| `unused_function` | warn | `def` function defined but never called |
| `unused_import` | warn | `import` module declared but never accessed |
| `unreachable_code` | error | Code following `break`/`continue` that can never execute |
| `infinite_loop` | warn | `loop` body without a `break` |
| `duplicate_match_arm` | error | Same pattern appears more than once in a `match` |
| `shadow_variable` | warn | Variable re-declared in an inner scope with the same name as an outer variable |
| `missing_else_in_expr` | warn | `if` expression with no `else` branch (evaluates to `none` on false) |
| `always_true_condition` | warn | `if` condition is a literal `true` or `false` |
| Rule ID | Severity | Fixable | Description |
| ----------------------- | -------- | ------- | ------------------------------------------------------------------------------ |
| `unused_variable` | warn | ✓ | `let`/`var` variable declared but never referenced |
| `unused_function` | warn | | `def` function defined but never called |
| `unused_import` | warn | | `import` module declared but never accessed |
| `unused_parameter` | warn | ✓ | Function parameter declared but never referenced |
| `unreachable_code` | error | | Code following `break`/`continue` that can never execute |
| `infinite_loop` | warn | | `loop` body without a `break` |
| `duplicate_match_arm` | error | | Same pattern appears more than once in a `match` |
| `shadow_variable` | warn | | Variable re-declared in an inner scope with the same name as an outer variable |
| `missing_else_in_expr` | warn | | `if` expression with no `else` branch (evaluates to `none` on false) |
| `always_true_condition` | warn | | `if` condition is a literal `true` or `false` |

"Fixable" rules can be auto-corrected with `mq-lint --fix` or their language server quick fix.

**Example — `unused_variable`**

Expand All @@ -95,6 +117,12 @@ let x = .h1; # warn: x is never used
.text
```

**Example — `unused_parameter`**

```mq
def greet(name, unused): s"Hello ${name}"; # warn: unused is never used
```

**Example — `missing_else_in_expr`**

```mq
Expand All @@ -109,16 +137,20 @@ if (true): 1 else: 2; # warn: condition is always `true`

### Style / Best Practices

| Rule ID | Severity | Description |
| --------------------------- | -------- | -------------------------------------------------------------------- |
| `prefer_let_over_var` | warn | `var` variable never reassigned — prefer `let` |
| `naming_convention` | style | Function or variable name is not `snake_case` |
| `boolean_comparison` | style | `x == true` → `x`, `x == false` → `not(x)` |
| `redundant_boolean_literal` | style | `if (cond): true else: false` simplifies to `cond` |
| `prefer_specific_heading` | style | `.h` without a level — prefer `.h1`–`.h6` |
| `prefer_coalesce` | style | `if (x == none): fallback else: x` simplifies to `x ?? fallback` |
| `prefer_pipe_style` | style | Nested unary call `f(g(x))` reads better as a pipe `x \| g() \| f()` |
| `redundant_try` | style | `try: ... catch: none` is exactly what the `?` operator does |
| Rule ID | Severity | Fixable | Description |
| --------------------------- | -------- | ------- | --------------------------------------------------------------------- |
| `prefer_let_over_var` | warn | ✓ | `var` variable never reassigned — prefer `let` |
| `naming_convention` | style | | Function or variable name is not `snake_case` |
| `boolean_comparison` | style | ✓ | `x == true` → `x`, `x == false` → `not(x)` |
| `redundant_boolean_literal` | style | ✓ | `if (cond): true else: false` simplifies to `cond` |
| `prefer_specific_heading` | style | | `.h` without a level — prefer `.h1`–`.h6` |
| `prefer_coalesce` | style | ✓ | `if (x == none): fallback else: x` simplifies to `x ?? fallback` |
| `prefer_pipe_style` | style | | Nested unary call `f(g(x))` reads better as a pipe `x \| g() \| f()` |
| `redundant_try` | style | ✓ | `try: ... catch: none` is exactly what the `?` operator does |
| `unnecessary_interpolation` | style | ✓ | `s"${x}"` (a single interpolation, nothing else) → `x` |
| `constant_string_concat` | style | ✓ | `"a" + "b"` (both literals) → `"ab"` |

"Fixable" rules can be auto-corrected with `mq-lint --fix` or their language server quick fix.

**Example — `prefer_let_over_var`**

Expand Down Expand Up @@ -162,6 +194,18 @@ to_text(to_upper(x)) # style: rewrite as `x | to_upper() | to_text()`
try: get("x") catch: none # style: rewrite as `get("x")?`
```

**Example — `unnecessary_interpolation`**

```mq
s"${.h1}" # style: rewrite as `.h1` — no interpolation needed for a single expr
```

**Example — `constant_string_concat`**

```mq
"hello" + " world" # style: rewrite as `"hello world"`
```

### Complexity

| Rule ID | Default Threshold | Description |
Expand Down
246 changes: 246 additions & 0 deletions crates/mq-lint/src/fix.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
//! Textual auto-fixes for lint diagnostics.
//!
//! Rules only see the HIR, not raw source text, so a [`Fix`] records ranges rather than strings
//! and is resolved against the source later, wherever it's available (the CLI or the LSP).

/// The core replacement text for a [`Fix`], before `prefix`/`suffix` are applied.
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum Core {
Literal(String),
Verbatim(mq_lang::Range),
Concat(Vec<Core>),
}

impl Core {
fn resolve(&self, source: &str) -> Option<String> {
match self {
Core::Literal(text) => Some(text.clone()),
Core::Verbatim(range) => Some(slice(source, *range)?.trim().to_string()),
Core::Concat(parts) => parts
.iter()
.map(|part| part.resolve(source))
.collect::<Option<Vec<_>>>()
.map(|parts| parts.concat()),
}
}
}

/// A suggested rewrite for the span of a diagnostic: `range` gets replaced with `prefix` +
/// resolved core text + `suffix`.
#[derive(Debug, Clone, PartialEq)]
pub struct Fix {
pub range: mq_lang::Range,
core: Core,
pub prefix: String,
pub suffix: String,
}

impl Fix {
/// A fix that replaces `range` with the source text spanned by `verbatim`, unchanged.
pub fn verbatim(range: mq_lang::Range, verbatim: mq_lang::Range) -> Self {
Self {
range,
core: Core::Verbatim(verbatim),
prefix: String::new(),
suffix: String::new(),
}
}

/// A fix that replaces `range` with fixed `text` known at rule-check time.
pub fn literal(range: mq_lang::Range, text: impl Into<String>) -> Self {
Self {
range,
core: Core::Literal(text.into()),
prefix: String::new(),
suffix: String::new(),
}
}

/// A fix that replaces `range` with several literal/verbatim `parts` concatenated together,
/// for rewrites that reorder or splice more than one sub-expression (see
/// [`crate::fix::Core`]).
pub(crate) fn concat(range: mq_lang::Range, parts: Vec<Core>) -> Self {
Self {
range,
core: Core::Concat(parts),
prefix: String::new(),
suffix: String::new(),
}
}

pub fn with_prefix(mut self, prefix: impl Into<String>) -> Self {
self.prefix = prefix.into();
self
}

pub fn with_suffix(mut self, suffix: impl Into<String>) -> Self {
self.suffix = suffix.into();
self
}

/// Resolves this fix against `source`, returning the range to replace and its replacement
/// text.
///
/// A `Verbatim` core is trimmed, since rules can often only bound one side of such a range by
/// a sibling token rather than the expression's own true end (see [`crate::LintContext::full_range`]).
pub fn resolve(&self, source: &str) -> Option<(mq_lang::Range, String)> {
let core = self.core.resolve(source)?;
Some((self.range, format!("{}{}{}", self.prefix, core, self.suffix)))
}
}

/// Converts a 1-based line/column [`mq_lang::Position`] (column counted in `char`s) into a byte
/// offset into `source`.
fn position_to_byte_offset(source: &str, position: mq_lang::Position) -> Option<usize> {
let mut offset = 0;
let mut lines = source.split_inclusive('\n');

for _ in 1..position.line {
offset += lines.next()?.len();
}
let line = lines.next().unwrap_or("");

let mut column = 1;
for (i, _) in line.char_indices() {
if column == position.column {
return Some(offset + i);
}
column += 1;
}
if column == position.column {
return Some(offset + line.trim_end_matches('\n').len());
}

None
}

/// The smallest range spanning both `a` and `b`.
pub fn union(a: mq_lang::Range, b: mq_lang::Range) -> mq_lang::Range {
let start = if (a.start.line, a.start.column) <= (b.start.line, b.start.column) {
a.start
} else {
b.start
};
let end = if (a.end.line, a.end.column) >= (b.end.line, b.end.column) {
a.end
} else {
b.end
};
mq_lang::Range { start, end }
}

/// Extracts the substring of `source` spanned by `range`.
pub fn slice(source: &str, range: mq_lang::Range) -> Option<&str> {
let start = position_to_byte_offset(source, range.start)?;
let end = position_to_byte_offset(source, range.end)?;
if start > end {
return None;
}
source.get(start..end)
}

/// Applies a set of resolved `(range, replacement)` edits to `source`, returning the new text.
///
/// Edits are applied from the end of the source towards the start so earlier byte offsets stay
/// valid; if two edits overlap, the one starting earlier wins and the other is dropped.
pub fn apply_edits(source: &str, edits: &[(mq_lang::Range, String)]) -> String {
let mut spans: Vec<(usize, usize, &str)> = edits
.iter()
.filter_map(|(range, text)| {
let start = position_to_byte_offset(source, range.start)?;
let end = position_to_byte_offset(source, range.end)?;
(start <= end).then_some((start, end, text.as_str()))
})
.collect();
spans.sort_by_key(|(start, ..)| std::cmp::Reverse(*start));

let mut result = source.to_string();
let mut applied_start = usize::MAX;
for (start, end, text) in spans {
if end > applied_start {
continue;
}
result.replace_range(start..end, text);
applied_start = start;
}
result
}

#[cfg(test)]
mod tests {
use super::*;

fn range(start_line: u32, start_col: usize, end_line: u32, end_col: usize) -> mq_lang::Range {
mq_lang::Range {
start: mq_lang::Position {
line: start_line,
column: start_col,
},
end: mq_lang::Position {
line: end_line,
column: end_col,
},
}
}

#[test]
fn slice_extracts_single_line_span() {
let source = ".checked == false";
assert_eq!(slice(source, range(1, 1, 1, 9)), Some(".checked"));
}

#[test]
fn slice_extracts_across_lines() {
let source = "let x =\n .h1\n| x";
assert_eq!(slice(source, range(2, 3, 2, 6)), Some(".h1"));
}

#[test]
fn slice_handles_multibyte_columns() {
let source = r#"s"${あ}""#;
// The interpolated expr `あ` starts at char column 5 (after `s`, `"`, `$`, `{`).
assert_eq!(slice(source, range(1, 5, 1, 6)), Some("あ"));
}

#[test]
fn resolve_applies_prefix_and_suffix() {
let source = ".checked == false";
let fix = Fix::verbatim(range(1, 1, 1, 18), range(1, 1, 1, 9)).with_prefix("!");
assert_eq!(fix.resolve(source), Some((range(1, 1, 1, 18), "!.checked".to_string())));
}

#[test]
fn resolve_uses_literal_core() {
let source = r#"s"${x}""#;
let fix = Fix::literal(range(1, 1, 1, 8), "x");
assert_eq!(fix.resolve(source), Some((range(1, 1, 1, 8), "x".to_string())));
}

#[test]
fn apply_edits_splices_single_edit() {
let source = ".checked == false";
let edits = vec![(range(1, 1, 1, 18), "!.checked".to_string())];
assert_eq!(apply_edits(source, &edits), "!.checked");
}

#[test]
fn apply_edits_handles_multiple_non_overlapping_edits() {
let source = "try: get(\"x\") catch: none\n| .checked == true";
let edits = vec![
(range(1, 1, 1, 26), "get(\"x\")?".to_string()),
(range(2, 3, 2, 19), ".checked".to_string()),
];
assert_eq!(apply_edits(source, &edits), "get(\"x\")?\n| .checked");
}

#[test]
fn apply_edits_drops_overlapping_edit() {
let source = ".checked == false";
let edits = vec![
(range(1, 1, 1, 18), "!.checked".to_string()),
(range(1, 1, 1, 9), ".checked".to_string()),
];
// The two edits overlap; only the one starting first (lowest offset) is kept.
assert_eq!(apply_edits(source, &edits), "!.checked");
}
}
Loading