You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

255 lines
11 KiB

#!/usr/bin/env tclsh
# linecont_lint.tcl - lint Tcl line-continuation backslashes outside doc blocks
#
# Per src/modules/AGENTS.md, line-continuation backslashes (\ at end of a line)
# must NOT appear in proc bodies or other executable code - they make debug
# line-number matching harder and the expand {dict create {*}...} / accumulator
# idioms are the required style. The ONLY allowed context is punk::args doc
# blocks (punk::args::define {...}, lappend PUNKARGS {...} / [list {...}]),
# where \ (legacy) or -& (preferred) record continuation is part of the
# definition dialect.
#
# This linter reports \ line-continuations in non-docblock contexts. It uses a
# real Tcl parser (the tclparser C library via 'package require parser', or the
# pure-Tcl punk::tclparser fallback wired by punk::lib::tclparser_prefer) to
# locate doc-block spans STRUCTURALLY (so the exemption is not a regex guess):
# the brace argument to punk::args::define, and the command range of
# 'lappend PUNKARGS ...'. Everything else - proc bodies, command lines,
# continuations inside [...] substitution, comments - is scanned for \newline
# and flagged. A \ inside a braced doc block is exempt; a \ inside a braced
# proc body is NOT (it is a real continuation when the body runs).
#
# Run under a punk environment so punk::lib can resolve the parser (the C lib
# ships in the punk9win kit; the pure-Tcl fallback is in src/modules/punk/tclparser):
# punk91 src script lib:developer/linecont_lint <path|glob|dir>...
# tclsh scriptlib/developer/linecont_lint.tcl <path|glob|dir>... (if parser present)
# Exit 0 when clean; exit 1 with one 'path:line: <msg>' line per finding; exit 2
# on usage error or if no parser is available.
#
# Limitations (v1):
# - The 'lappend PUNKARGS ...' exemption is by whole command range (covers both
# 'lappend PUNKARGS {doc}' and 'lappend PUNKARGS [list {doc}]'); a \ on the
# same physical line as 'lappend PUNKARGS' but outside the doc would be
# missed - acceptable (doc authoring only).
# - A \ inside a braced DATA literal that is not a doc block (e.g. a multi-line
# braced string constant passed to a non-doc command) is flagged as a
# false positive - rare in practice; the expand idiom avoids it.
# - The parser's 'parse command' wrapper does not expose [...] sub-commands, so
# the linter cannot exempt a doc block nested inside [...] - but doc blocks
# are not authored that way, so this is a non-issue.
# --- parser bootstrap ------------------------------------------------------
set parse_cmd ""
if {![catch {package require parser}]} {
set parse_cmd ::parse
} elseif {![catch {package require punk::lib}]} {
catch {punk::lib::tclparser_prefer c}
if {[llength [info commands ::punk::lib::parse]]} {
set parse_cmd ::punk::lib::parse
} else {
catch {punk::lib::tclparser_prefer tcl}
if {[llength [info commands ::punk::lib::parse]]} {
set parse_cmd ::punk::lib::parse
}
}
}
if {$parse_cmd eq ""} {
puts stderr "linecont_lint: no Tcl parser available - need 'package require parser' (C tclparser) or punk::lib (pure-Tcl fallback). Run via 'punk91 src script lib:developer/linecont_lint ...'."
exit 2
}
set script_body_cmds {proc apply lambda if elseif else while for foreach try catch eval uplevel namespace time}
proc is_doc_cmd {words} {
set cmd [lindex $words 0]
if {$cmd eq "punk::args::define" || $cmd eq "::punk::args::define"} { return 1 }
if {$cmd eq "lappend" || $cmd eq "::lappend"} {
if {[lindex $words 1] eq "PUNKARGS"} { return 2 }
}
return 0
}
proc is_script_body_cmd {cmd} {
variable script_body_cmds
expr {$cmd in $script_body_cmds || $cmd in [lmap c $script_body_cmds {list ::$c}]}
}
#collect exempt spans (doc blocks) into ::exempt, as [start end) offsets in the
#FILE text ($text). $script is the substring being parsed, $base its offset.
proc collect_doc_spans {text script base} {
#Parse a SUFFIX substring at each step (always {0 end}) and adjust offsets
#by +$pos. The parser's range-start argument is honoured in isolation but
#misbehaves mid-string around backslash-continuations; feeding a suffix
#avoids that fragility entirely. Token offsets returned by the parser are
#relative to $suffix; word extraction uses $suffix, brace detection and
#body extraction shift by +$pos into the $script frame.
set pos 0
set slen [string length $script]
set iter 0
while {$pos < $slen} {
incr iter
if {$iter > 500000} { puts stderr "linecont_lint: bailed (iteration cap) collecting doc spans at base $base"; return }
set suffix [string range $script $pos end]
set rc [catch {$::parse_cmd command $suffix {0 end}} res]
if {$rc} { return }
lassign $res commentRange commandRange remainderRange tokens
set cstart [expr {[lindex $commandRange 0] + $pos}]
set clen [lindex $commandRange 1]
#comment span (covers \ in a comment line - harmless comment continuation,
#not a command continuation): record as exempt. Present for both
#comment-only (clen 0) and command-with-leading-comment (clen>0) parses.
set cmt_start [expr {[lindex $commentRange 0] + $pos}]
set cmt_len [lindex $commentRange 1]
if {$cmt_len > 0} {
lappend ::exempt [list [expr {$base + $cmt_start}] [expr {$base + $cmt_start + $cmt_len}]]
}
if {$clen == 0} {
set adv [expr {[lindex $commentRange 0] + [lindex $commentRange 1] + $pos}]
if {$adv <= $pos} { incr adv }
set pos $adv
continue
}
set cend [expr {$cstart + $clen}]
if {$cend <= $pos} { set pos [expr {max($cend, $pos) + 1}]; continue }
#word strings (tokens are suffix-relative -> use $suffix)
set words [list]
foreach tok $tokens {
lappend words [token_word $suffix $tok]
}
set doc [is_doc_cmd $words]
if {$doc == 1} {
set tokidx 0
foreach tok $tokens {
if {$tokidx == 0} { incr tokidx; continue }
lassign [token_outer $tok] sost solen
set ostart [expr {$sost + $pos}]
set olen $solen
if {$olen >= 2 && [string index $script $ostart] eq "\{"} {
lappend ::exempt [list [expr {$base + $ostart}] [expr {$base + $ostart + $olen}]]
}
incr tokidx
}
} elseif {$doc == 2} {
lappend ::exempt [list [expr {$base + $cstart}] [expr {$base + $cend}]]
}
#recurse into script-body braces to find nested doc blocks (e.g. a
#punk::args::define inside a namespace eval argdoc inside a proc).
set cmd [lindex $words 0]
if {($cmd eq "dict" || $cmd eq "::dict") && [lindex $words 1] in {update with}} {
set last [lindex $tokens end]
lassign [token_outer $last] sost solen
set ostart [expr {$sost + $pos}]
set olen $solen
if {$olen >= 2 && [string index $script $ostart] eq "\{"} {
set body [string range $script [expr {$ostart+1}] [expr {$ostart+$olen-2}]]
collect_doc_spans $text $body [expr {$base + $ostart + 1}]
}
} elseif {[is_script_body_cmd $cmd]} {
foreach tok [lrange $tokens 1 end] {
lassign [token_outer $tok] sost solen
set ostart [expr {$sost + $pos}]
set olen $solen
if {$olen >= 2 && [string index $script $ostart] eq "\{"} {
set body [string range $script [expr {$ostart+1}] [expr {$ostart+$olen-2}]]
collect_doc_spans $text $body [expr {$base + $ostart + 1}]
}
}
}
set pos $cend
}
}
#token helpers. tok: {simple {outerStart outerLen} {{text {innerStart innerLen} {value}} ...}}
proc token_outer {tok} {
set r [lindex $tok 1]
return [list [lindex $r 0] [lindex $r 1]]
}
proc token_word {script tok} {
if {[llength $tok] < 3} { return "" }
set firstsub [lindex [lindex $tok 2] 0]
set irange [lindex $firstsub 1]
set istart [lindex $irange 0]
set ilen [lindex $irange 1]
return [string range $script $istart [expr {$istart+$ilen-1}]]
}
proc offset_to_line {text off} {
set n 1
for {set i 0} {$i < $off && $i < [string length $text]} {incr i} {
if {[string index $text $i] eq "\n"} { incr n }
}
return $n
}
proc in_exempt {off} {
foreach sp $::exempt {
if {$off >= [lindex $sp 0] && $off < [lindex $sp 1]} { return 1 }
}
return 0
}
set findings [list]
proc flag {path line msg} { lappend ::findings "$path:$line: $msg" }
# --- file collection -------------------------------------------------------
proc collect_files {args} {
set files [list]
foreach a $args {
if {[string match {*[\[\?\*]*} $a]} {
foreach f [glob -nocomplain -type f $a] { lappend files $f }
} elseif {[file isfile $a]} {
lappend files $a
} elseif {[file isdir $a]} {
foreach f [glob -nocomplain -type f -directory $a -- *.tcl *.tm] { lappend files $f }
}
}
return $files
}
# --- main: sourceable proc + script-mode guard ----------------------------
# linecont_lint_run <files...> -> list of finding strings (empty if clean).
# Sourceable: when this file is sourced (not run as a script), the proc is
# defined and nothing runs; callers invoke it and read the result. When run as a
# script, the trailing guard invokes it and exits with 0/1/2.
proc linecont_lint_run {files} {
set ::exempt [list]
set ::findings [list]
foreach f $files {
set fd [open $f r]
set text [read $fd]
close $fd
set ::exempt [list]
collect_doc_spans $text $text 0
set tlen [string length $text]
for {set i 0} {$i < $tlen} {incr i} {
if {[string index $text $i] ne "\\"} continue
set nx [string index $text [expr {$i+1}]]
if {$nx ne "\n" && $nx ne "\r"} continue
if {![in_exempt $i]} {
flag $f [offset_to_line $text $i] "line-continuation backslash outside a punk::args doc block (use expand {dict create {*}...} / accumulators, or -& in a doc block, per src/modules/AGENTS.md)"
}
incr i
}
}
return $::findings
}
#script-mode only: when this file is the invoked script, parse argv and exit.
if {[info exists ::argv0] && [file normalize $::argv0] eq [file normalize [info script]]} {
if {$argc == 0} {
puts stderr "usage: $argv0 <path|glob|dir>...
lints Tcl .tcl/.tm files for line-continuation backslashes outside punk::args doc blocks"
exit 2
}
set files [collect_files {*}$argv]
if {![llength $files]} {
puts stderr "linecont_lint: no .tcl/.tm files matched: $argv"
exit 2
}
set findings [linecont_lint_run $files]
if {[llength $findings]} {
foreach fl $findings { puts $fl }
puts stderr "linecont_lint: [llength $findings] finding(s) across [llength $files] file(s)"
exit 1
}
puts stderr "linecont_lint: clean ([llength $files] file(s) checked)"
exit 0
}