Updated 2007-05-19 11:43:10 by sarnold

Sarnold (2006 09 17) A GUI for advanced interactive search and replace with regular expressions. forward-compatible dict implementation is included in it.

If people are interessed, I will put it into the tcllib repository (tclapps).

LV Yes, please do add it.

Sarnold Done 2007-05-07.
                package require Tcl 8.4
        package require Tk 8.4
        # ATTENTION PLEASE!
        catch {package require dict}
        # Poor man's dict -- a pure tcl [dict] emulation
        # Very slow, but complete.
        #
        # Not all error checks are implemented!
        # e.g. [dict create odd arguments here] will work
        #
        # Implementation is based on lists, [array set/get]
        # and recursion

        if {![llength [info commands dict]]} {
            proc dict {cmd args} {
                uplevel 1 [linsert $args 0 _dict_$cmd]
            }
            proc _dict_get {dv args} {
                if {![llength $args]} {return $dv} else {
                    array set dvx $dv
                    set key [lindex $args 0]
                    set dv $dvx($key)
                    set args [lrange $args 1 end]
                    return [eval [linsert $args 0 _dict_get $dv]]
                }
            }
            proc _dict_exists {dv key args} {
                array set dvx $dv
                set r [info exists dvx($key)]
                if {!$r} {return 0}
                if {[llength $args]} {
                    return [eval [linsert $args 0 _dict_exists $dvx($key) ]]
                } else {return 1}
            }
            proc _dict_set {dvar key value args } {
                upvar 1 $dvar dv
                if {![info exists dv]} {set dv [list]}
                array set dvx $dv
                if {![llength $args]} {
                    set dvx($key) $value
                } else {
                    eval [linsert $args 0 _dict_set dvx($key) $value]
                }
                set dv [array get dvx]
            }
            proc _dict_unset {dvar key args} {
                upvar 1 $dvar mydvar
                if {![info exists mydvar]} {return}
                array set dv $mydvar
                if {![llength $args]} {
                    if {[info exists dv($key)]} {
                        unset dv($key)
                    }
                } else {
                    eval [linsert $args 0 _dict_unset dv($key) ]
                }
                set mydvar [array get dv]
                return {}
            }
            proc _dict_keys {dv {pat *}} {
                array set dvx $dv
                return [array names dvx $pat]
            }
            proc _dict_append {dvar key {args}} {
                upvar 1 $dvar dv
                if {![info exists dv]} {set dv [list]}
                array set dvx $dv
                eval [linsert $args 0 append dvx($key) ]
                set dv [array get dvx]
            }
            proc _dict_create {args} {
                return $args
            }
            proc _dict_filter {dv ftype args} {
                set r [list]
                foreach {globpattern} $args {break}
                foreach {varlist script} $args {break}

                switch $ftype {
                    key {
                        foreach {key value} $dv {
                            if {[string match $globpattern $key]} {
                                lappend r $key $value
                            }
                        }
                    }
                    value {
                        foreach {key value} $dv {
                            if {[string match $globpattern $value]} {
                                lappend r $key $value
                            }
                        }
                    }
                    script {
                        foreach {Pkey Pval} $varlist {break}
                        upvar 1 $Pkey key $Pval value
                        foreach {key value} $dv {
                            if {[uplevel 1 $script]} {
                                lappend r $key $value
                            }
                        }
                    }
                    default {
                        error "Wrong filter type"
                    }
                }
                return $r
            }
            proc _dict_for {kv dict body} {
                uplevel 1 [list foreach $kv $dict $body]
            }
            proc _dict_incr {dvar key {incr 1}} {
                upvar 1 $dvar dv
                if {![info exists dv]} {set dv [list]}
                array set dvx $dv
                if {![info exists dvx($key)]} {set dvx($key) 0}
                incr dvx($key) $incr
                set dv [array get dvx]
            }
            proc _dict_info {dv} {
                return "Dictionary is represented as plain list"
            }
            proc _dict_lappend {dvar key args} {
                upvar 1 $dvar dv
                if {![info exists dv]} {set dv [list]}
                array set dvx $dv
                eval [linsert $args 0 lappend dvx($key)]
                set dv [array get dvx]
            }
            proc _dict_merge {args} {
            foreach dv $args {
                array set dvx $dv
            }
            array get dvx
            }
            proc _dict_replace {dv args} {
                foreach {k v} $args {
                    _dict_set dv $k $v
                }
                return $dv
            }
            proc _dict_remove {dv args} {
                foreach k $args {
                    _dict_unset dv $k
                }
                return $dv
            }
            proc _dict_size {dv} {
                return [expr {[llength $dv]/2}]
            }
            proc _dict_values {dv {gp *}} {
                set r [list]
                foreach {k v} $dv {
                    if {[string match $gp $v]} {
                        lappend r $v
                    }
                }
                return $r
            }
        }

        proc Error {str} {
                tk_messageBox -message $str
                return -code return $str
        }

        proc SelectDir {varname} {
                upvar $varname d
                set d [tk_chooseDirectory -initialdir $d -title "Choose a directory"\
                        -parent .opt]
        }

        proc main {} {
                toplevel .opt
                wm title .opt "Parameters"
                set row 1
                set fr  .opt
                foreach key {dir filter search replace backup} desc {
                        "Start directory" "File filter" "Search for"
                        "Replace with" "Create backup files"
                } {
                    if {![info exists ::t($key)]} {
                        set ::t($key) ""
                    }
                    switch -- $key {
                        dir {
                            label $fr.lb$row -text $desc
                            grid $fr.lb$row -row $row -column 0
                            set fri [frame $fr.f$row]
                            grid $fri -row $row -column 1
                            button $fri.ds -text Choose -command [list SelectDir ::t($key)]
                            entry $fri.en -textvariable ::t($key)
                            pack $fri.en $fri.ds -padx 5 -pady 5 -side left
                        }
                        backup {
                            checkbutton $fr.ck$row -text $desc -variable ::t($key)
                            set ::t($key) 1
                            grid $fr.ck$row -row $row -column 0
                        }
                        default {
                            label $fr.lb$row -text $desc
                            entry $fr.en$row -textvariable ::t($key)
                            grid $fr.lb$row -row $row -column 0
                            grid $fr.en$row -row $row -column 1
                        }
                    }
                    eval grid configure [winfo children $fr] -padx 5 -pady 5
                    incr row
                }
                set fr [frame .opt.cmd]
                button $fr.val -text Proceed -command execute
                button $fr.exit -text Abort -command abort
                pack $fr.val $fr.exit -side left -padx 10 -pady 10
                grid $fr -row $row -column 0 -columnspan 2
                focus .opt
                lower .
                raise .opt
        }

        proc abort {} {
            if {[tk_messageBox -type yesno -message "Do you really want to exit?"]} {
                global FILE
                file delete -force $FILE
                exit 0
            }
        }

        proc execute {} {
                destroy .opt
                raise .
                focus -force .
                # re-active all buttons
                butstate normal
                set opts [eval dict create [array get ::t]]
                if {![file isdirectory [dict get $opts dir]]} {
                        Error "[dict get $opts dir] is not a valid directory"
                }
                dict for {k v} $opts {if {$k ne "replace" && $v eq ""} {Error "Empty $k parameter"}}

                if {![dict get $opts backup] &&
                    ![tk_messageBox -type yesno -message \
                    "Files will not be saved as backup files.\nCheck you already did the backup.\nDo you want to continue?"]} {
                    main
                    return
                }

                # Create the substitution proc (regexps are compiled, thus faster)
                set search [dict get $opts search]
                set replace [dict get $opts replace]
                proc Replace {line} [string map [list SRCH $search REPL $replace] {
                        if {![regexp -indices -line -- {SRCH} $line location]} {return [list $line]}
                        foreach {b e} $location {break}
                        # three parts of the string : before, at, and after the match
                        set result [list [string range $line 0 [expr {$b-1}]]\
                                [string range $line $b $e] [string range $line [expr {$e+1}] end]]
                        # append the subst'd string
                        lappend result [regsub -line -- {SRCH} [lindex $result 1] {REPL}]
                }]
                if {[catch {Replace ""} msg]} {
                    Error "Invalid search/replace pattern pair:\n$msg"
                }
                directory $opts
                butstate disabled
                if {[tk_messageBox -type yesno \
                -message "Operation completed\nDo you want to exit ?"]} {
                        exit
                }
                main
        }
        proc fcontext {opts} {
                set dir [dict get $opts dir]
                set context [dict create dirs "" files ""]
                if {[catch {set flist [lsort [glob $dir/*]]}]} {
                        # no more files
                        return $context
                }
                set filter [dict get $opts filter]
                foreach f $flist {
                    if {[file isdirectory $f]} {
                        dict lappend context dirs $f
                    }
                }
                foreach f [glob -nocomplain $dir/$filter] {
                    if {![file isdirectory $f]} {
                        dict lappend context files $f
                    }
                }
                return $context
        }

        proc ScrollOpen {path} {
                global ROWNUM
                set res [dict create path $path fd [set fd [open $path]]\
                cache "" now -1 clen 0]
                while {![eof $fd] ||
                [dict get $res clen] == $ROWNUM} {
                        dict lappend res cache [gets $fd]
                        dict incr res clen
                }
                return $res
        }

        proc ScrollEof {in} {
                set fd [dict get $in fd]
                if {[dict get $in now] >= [dict get $in clen] - 1} {return yes}
                return no
        }

        proc ScrollClose {in} {
                close [dict get $in fd]
        }

        proc ScrollGets {in} {
                global ROWNUM
                set fd [dict get $in fd]
                if {[dict get $in now] <= $ROWNUM/2} {
                        # head of the file or window larger than the file
                        dict incr in now
                } elseif {![eof $fd]} {
                        # middle of the file
                        set cache [lrange [dict get $in cache] 1 end]
                        lappend cache [gets $fd]
                        dict set $in cache $cache
                } else {
                        # end of the file
                        dict incr in now
                }
                return [list [lindex [dict get $in cache] [dict get $in now]] $in]
        }

        proc File {path opts} {
                ShowFile $path $opts
                global STATUS FILE
                set STATUS proceed
                set STATUS proceed
                if {[dict get $opts backup]} {
                    if {[catch {file copy -force $path $path.bak}]} {
                        Error "cannot write $path.bak"
                    }
                }
                # open the chosen file
                # ScrollOpen/Close/Gets keeps into a dict
                # a view of 25 lines of code, with a cache
                # that anticipates reading file
                set in [ScrollOpen $path]
                # open a temporary file to write modified content to it
                set out [open [dict get $FILE TMP] w]
                set STATUS ""
                set previous [list]
                # store whether the file has been modified or not
                set changed no
                while {![ScrollEof $in]} {
                    foreach {line in} [ScrollGets $in] {break}
                    set search [dict get $opts search]
                    if {[regexp -line -- $search $line]} {
                        set newline [ShowReplace $in $line]
                        if {![string equal $newline $line]} {
                            # the file has been modified
                            set changed yes
                        }
                        switch -- $STATUS {
                            eof -
                            parentdir -
                            eod {
                                return
                            }
                            cancel {
                                puts $out $line
                            }
                            proceed - default {
                                puts $out $newline
                            }
                        }
                    } else {
                        puts $out $line
                    }
                }
                close $out
                ScrollClose $in
                if {$changed} {
                    if {[catch {file copy -force [dict get $FILE TMP] $path} err]} {
                        Error "Cannot write result into $path:\n$err"
                    }
                }
        }

        proc ShowReplace {in line} {
                global STATUS
                set chunks [Replace $line]
                set outline [lindex $chunks 0]
                while {[llength $chunks] == 4} {
                        ShowMatchingPiece $chunks $in
                        if {$STATUS ne "auto"} {
                                tkwait variable STATUS
                        }
                        switch -- $STATUS {
                                eof -
                                parentdir -
                                eod {
                                        return ""
                                }
                                auto -
                                proceed {
                                        append outline [lindex $chunks 3]
                                }
                                cancel {
                                        append outline [lindex $chunks 1]
                                }
                        }
                        set chunks [Replace [lindex $chunks 2]]
                        append outline [lindex $chunks 0]
                        lset chunks 0 $outline
                }
                return $outline
        }

        proc ShowMatchingPiece {chunks in} {
                .snap delete 0.0 end
                .snap delete end
                global ROWNUM COLNUM
                set now [dict get $in now]
                set cache [dict get $in cache]
                set before [Align [lrange $cache 0 [expr {$now-1}]] $COLNUM]
                set after [Align [lrange $cache [expr {$now+1}] end] $COLNUM]
                set now [expr {[string length [concat $chunks]]/$COLNUM}]
                if {$now > $ROWNUM} {
                        .snap insert 0 "Line too long"
                        return
                }
                foreach {head match end replace} $chunks {break}
                set linesbefore [expr {($ROWNUM - $now)/2}]
                set linesafter  [expr {$ROWNUM - $now - $linesbefore}]
                set before [lrange $before end-[expr {2*$linesbefore-1}] end]
                set after [lrange $after 0 [expr {2*$linesafter-1}]]
                foreach {type line} $before {
                        puts $type,$line,before
                        .snap insert end $line\n $type
                }
                .snap insert end $head line
                .snap insert end $match match
                .snap insert end $replace replace
                .snap insert end $end\n line
                foreach {type line} $after {
                        puts $type,$line,after
                        .snap insert end $line\n $type
                }
                interp alias {} snaptag {} .snap tag configure
                snaptag match -overstrike yes -background #f88
                snaptag replace -background #8f8
                snaptag line -background #ee8
                snaptag newline -background #ccf
                snaptag next -background #eef
                update
        }

        proc Align {lines colnum} {
                set r ""
                foreach l $lines {
                        lappend r newline
                        if {$l eq ""} {lappend r ""}
                        while {$l ne ""} {
                                lappend r [string range $l 0 [incr colnum -1]]
                                set l [string range $l [incr colnum] end]
                                if {$l ne ""} {lappend r next}
                        }
                }
                return $r
        }

        proc directory {opts} {
                global STATUS
                set d [dict get $opts dir]
                if {![dict exists $opts level]} {
                        dict set opts level 0
                } else {
                        dict incr opts level
                }
                ShowDir $opts
                set fcontext [fcontext $opts]
                foreach d [dict get $fcontext dirs] {
                        dict set opts dir $d
                        directory $opts
                }
                dict incr opts level
                foreach f [dict get $fcontext files] {
                        File $f $opts
                        if {$STATUS eq "eod"} {return}
                        if {$STATUS eq "parentdir"} {return -code return}
                }
                return
        }

        proc ftext {level dir} {
                set t ""
                if {$level>0} {
                        set t +
                        incr level -1
                }
                set t [string repeat | $level]$t$dir
                global COLNUM
                set l [string length $t]
                if {$l > $COLNUM} {
                        set t [string range $t 0 [expr {$COLNUM - 4}]]...
                }
                return $t
        }

        proc ShowDir {data} {
                global COLNUM
                set dirname [lindex [file split [dict get $data dir]] end]
                set t [ftext [dict get $data level] "$dirname/ (directory)"]
                .p.progress insert end \n$t
                .p.progress see end
                update
        }

        proc ShowFile {path data} {
                global COLNUM
                set t [ftext [dict get $data level] [file tail $path]]
                .p.progress insert end \n$t
                .p.progress see end
                update
        }

        foreach {p status} {
            ExecuteOneChange proceed
            CancelOneChange cancel
            ExecuteAllChanges auto
            SkipFile eof
            SkipDir eod
            SkipParentDir parentdir
        } {
            interp alias {} $p {} set ::STATUS $status
        }

        proc buildGUI {} {
                global COLNUM ROWNUM
                set font [font create -family Courier -size 8]
                text .snap -width $COLNUM -height $ROWNUM -font $font;# -state disabled
                frame .p
                text .p.progress -width $COLNUM -height 8 -yscrollcommand {.p.scroll set}\
                        -font $font
        #               -state disabled
                scrollbar .p.scroll -command {.p.progress yview}
                pack .snap .p
                grid .p.progress .p.scroll -sticky nsew
                frame .f -pady 10
                button .f.ex -text "Change" -command ExecuteOneChange
                button .f.can -text "Don't Change Here" -command CancelOneChange
                button .f.exa -text "Change all" -command ExecuteAllChanges
                button .f.sk -text "Skip File" -command SkipFile
                button .f.skd -text "Skip Current Directory" -command SkipDir
                button .f.skpd -text "Skip Parent Directory" -command SkipParentDir
                button .f.quit -text Abort -command abort
                button .f.new -text "New session" -command main
                pack .f
                pack .f.ex .f.can .f.exa .f.sk .f.skd .f.skpd .f.quit -side left -padx 10
        }
        set COLNUM 90
        set ROWNUM 25
        package require fileutil
        set FILE [dict create TMP [::fileutil::tempfile tsreplace]]
        set STATUS begin
        wm title . "Tcl Star Replace v0.1.3"
        buildGUI
        proc butstate {newstate} {
                foreach b {.f.ex .f.can .f.exa .f.sk .f.skd .f.skpd} {$b configure -state $newstate}
        }
        butstate disabled
        main

20061224 Sarnold updated to v0.1.1.

RT looks quite useful, I tried a "no-op" where the pattern wasn't found and it still modified all the files. That behavior was an unwelcome surprise.

Sarnold Do you mean: the files were modified, but held the same content? I am a bit curious to know what pattern you did use...RT I mean the files all had their modification date changed to the current minute, which I suppose means they were all re-written. The contents didn't change. I used a nonsense pattern like s/monkeybait/monkeymeat and the pattern had no hits.

Sarnold Fixed... and moved to v0.1.2.

Sarnold Fixed a blocking, very annoying bug that causes some data loss, and the impossibility to take replacements into account, on the filesystem. All my apologies... Now at v0.1.3. (2007-05-06)

[Category Dev. Tools | Category File | Category GUI | Category String Processing | Category Whizzlet]