Skip to content

Add a lint layer with selectable rules, following ruff's model - #4

Closed
MarioRial22 wants to merge 5 commits into
scriptandcompile:masterfrom
seguridadea1:lint-rules
Closed

Add a lint layer with selectable rules, following ruff's model#4
MarioRial22 wants to merge 5 commits into
scriptandcompile:masterfrom
seguridadea1:lint-rules

Conversation

@MarioRial22

Copy link
Copy Markdown

Implements what #3 proposes. Stacks on #2 — GitHub shows those four commits here too; the last commit, Add a lint layer with selectable rules, is what this PR is about. Happy to rebase once #2 lands.

What it adds

aspen lint, per-file and parse-only, so it stays fast enough for a pre-commit hook — unlike check, which needs a .vbp and runs the whole semantic analysis.

Every rule carries a stable code, a fixability and a default; a run selects by code or code prefix.

$ aspen lint --explain
CODE   NAME                     DEFAULT  FIX      SUMMARY
W001   mixed-line-endings       on       safe     file mixes CRLF and LF line endings
W002   trailing-whitespace      on       safe     line ends in whitespace
N001   non-ascii-in-code        off      none     identifier contains a character outside ASCII

$ aspen lint --select N001 .
Forms/FrmClientes.frm:412:18: N001 identifier contains a character outside ASCII: ñ

Selection also comes from a [lint] section of the .aspen.toml that is already read for [fmt]. Exit codes follow ruff: 0 clean, 1 findings, 2 the run itself failed.

Two things worth reviewing

N001 reads the failure list, not the CST. It cannot be written as "an Identifier token holding a non-ASCII character": the lexer takes identifiers with take_ascii_underscore_alphanumerics and stops at the first non-ASCII byte, so Añadir never becomes one token. The stray character reaches the tokenizer fallback, which records an UnknownToken failure and pushes no token, so it is absent from the CST entirely.

That is also what makes the rule precise: comments and string literals are consumed whole by their own branches, so an accent in product text never reaches the fallback.

It skips lines that continue a comment. VB6 continues a logical line with a trailing _, and that applies to comments — the lexer does not model this, so the next line is read as code and every accent in that prose looks like an identifier. On the code base I tested this on the difference was 2898 findings versus 2247, and the 651 removed were entirely accented vowels from Spanish prose. The remaining findings are all ñ, which is what Spanish identifiers actually use (Año, Añadir, Diseño).

The skip lives in the rule rather than the lexer, because changing how comments tokenize is a much larger change than this PR should carry. It is probably worth doing properly — say the word and I will open it separately.

Tests

Seven tests in vb6format::lint: each rule's positive and negative case, the comment-continuation skip, selection by code and by prefix with ignore winning over select, and a registry check that every code is unique and reachable.

cargo test -p vb6format: 59 pass. Workspace-wide, the same eight pre-existing class_load snapshot failures as on master (no committed .snap baseline), unrelated to this.

Mario Rial added 5 commits August 13, 2026 03:49
tokenize() ends its loop with a fallback that consumes one character and
reports it as an unknown token, so the loop always makes progress. That
fallback used SourceStream::take_count(1), which works in *bytes*: for any
multi-byte character it returns None, because a single byte lands in the
middle of it. Nothing was consumed, no branch matched, and the loop spun
forever at 100% CPU.

Any non-ASCII character in code outside of a comment or a string literal
reaches that fallback. Comments and string literals are consumed whole by
their own branches, which is why this went unnoticed: the character has to
sit in code. Accented identifiers do exactly that, and VB6 accepts them, so
this is reachable from real source files. Six of the eleven projects in the
codebase I tried this on hang; the smallest reproduction is a single line,
"x = ñ".

Add SourceStream::take_character(), which consumes exactly one char however
many bytes it occupies, and use it for the fallback. As a safety net, break
out of the loop if the stream reports that it is not empty yet nothing can
be taken from it: a tokenizer that cannot advance should stop rather than
hang.
The library reads VB6 source files as Windows-1252 but has no way to write
one back. Anything that reads a file, changes it and saves it therefore has
to fall back on writing a Rust string straight out, which silently re-encodes
the file as UTF-8; VB6 then reads every accented character incorrectly.

Encoding is refused rather than made lossy. encoding_rs maps a character
outside Windows-1252 to '?', losing it without telling anyone, so an
unrepresentable character is returned as an error with its byte offset.

Tested by cycling all 256 byte values through decode and back, which is the
strongest available check that writing is lossless: Windows-1252 decodes
every byte, so the whole range must survive.
fmt reformatted the whole of a .frm, including the VERSION header and the
Begin ... End block. On a small form here that rewrote VERSION as Version,
flattened every control to column zero and dropped the block from 3436 to
2796 bytes.

That section is not source code. The VB6 IDE writes it and reads it, and
rewrites it in its own layout every time the form is saved, so reformatting
it comes straight back as diff noise on the next save. It is also where the
control geometry of a form lives, which makes silently rewriting it the
riskiest thing a formatter can do to a VB6 project.

Copy VersionStatement and PropertiesBlock subtrees through unchanged. The
same nodes cover the header of a .cls, so class files are protected too.
Formatting of the code that follows the block is unaffected, which is what
the third test pins down.
…essed

Two problems that together made fmt unusable on a non-English code base.

Reading used std::fs::read_to_string, which requires valid UTF-8. Every VB6
source file holding an accented character was rejected. On the code base I
tried this on that is 364 of 375 files, and the summary still printed "11 of
375 files would be reformatted", presenting the other 364 as fine. Read
through SourceFile, the way the rest of the tool already does, and write back
with encode_windows_1252 so a formatted file does not silently turn into
UTF-8.

Failures were also folded into "unchanged" and the exit code depended only on
the number of files that would be reformatted. A run in which every file
failed to read reported the errors and exited zero, so fmt --check passed in
exactly the case where it should fail, which is worthless as a CI gate.
Failures are now counted separately, summarised on stderr after the file
list, and produce a non-zero exit in both check and write mode.
Neither of the two existing commands has anywhere to put a finding that will
not be fixed automatically. `vb6format` is fix-only: a `FormatPass` can
rewrite tokens and `fmt_source` returns a `String`, so a pass cannot say
"this is wrong and I am deliberately not touching it". `check` is
project-only: it needs a `.vbp`, runs the whole semantic analysis and stops
at the first error.

That leaves out the checks that are cheap, per-file and not safely fixable.
The one that prompted this is a non-ASCII character in an identifier: VB6
accepts `Public Function Añadir()`, a code base written in Spanish is full of
them, almost nothing downstream of VB6 accepts them, and renaming one is a
decision about a public API rather than something a formatter should do
behind your back.

The shape follows ruff, since the vocabulary is already familiar: every rule
carries a stable code, a fixability (Safe, Unsafe, None) and a default, and a
run selects by code or code prefix through `--select` / `--ignore` or a
`[lint]` section in the `.aspen.toml` that is already read for `[fmt]`. Exit
codes follow the same convention: 0 clean, 1 findings, 2 the run itself
failed, so CI can tell "your code has a problem" from "the tool could not
look".

Three rules to establish the shape: mixed-line-endings and trailing-
whitespace (safe fixes, on by default) and non-ascii-in-code (no fix, off by
default).

Two things worth knowing about that last rule. It cannot be written as "an
Identifier token holding a non-ASCII character", because the lexer takes
identifiers with take_ascii_underscore_alphanumerics and stops at the first
non-ASCII byte: `Añadir` never becomes one token, and the stray character is
recorded as an UnknownToken failure with no token pushed, so it is absent
from the CST entirely. The failure list is where it lives. And it skips lines
that continue a comment: VB6 continues a logical line with a trailing `_` and
that applies to comments, which the lexer does not model, so prose from a
continued comment otherwise reaches the token stream and every accent in it
looks like an identifier. On the code base I tested this on that was the
difference between 2898 findings and 2247, and the 651 it removed were
entirely accented vowels from Spanish prose.

Builds on the fixes in scriptandcompile#2 -- the hang has to go first, or a file with an
accented identifier never finishes tokenizing, and the Windows-1252 read has
to go first, or the linter cannot open the files this rule is about.

Discussed in scriptandcompile#3.
@MarioRial22

Copy link
Copy Markdown
Author

Closing in favour of #6, after a rethink prompted by a good question: why a new lint command when check is already the place to ask whether something is wrong with the code?

The honest answer is that I designed around check's limitations instead of naming them. check stopped at the first error, needed a project and always exited zero — so I put the rules somewhere else rather than proposing to fix any of that. And invoking ruff made it worse, because in ruff check is the linter, and F821 undefined-name is a semantic rule living right next to the stylistic ones. The split I drew does not exist in the model I said I was following.

#6 fixes the three limitations and puts the rules in check, where they belong. Same rules, same registry, same config; two commands instead of three.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant