AtlatestRepositorysigil-format
sigil-format / tree / src / sigilformat.sgl
1
;;; (sigil format) - Code Formatter with Paren Inference2
;;;3
;;; This library provides code formatting for Sigil with the ability to4
;;; infer missing parentheses from indentation patterns.5
;;;6
;;; Key insight: In Lisp, indentation encodes programmer intent. When7
;;; indentation decreases, it signals that forms should close. Using 2-space8
;;; convention: column 0 = depth 0, column 2 = depth 1, etc.9
;;;10
;;; Example:11
;;; (format-file "broken.msc") ; → <format-result>12
;;; (format-string "(define (foo" "test.msc") ; → <format-result>14
(define-library (sigil format)15
(import (sigil struct)16
(sigil string)17
(sigil io)18
(sigil fs)19
(sigil format tokenize))21
(export22
;; Core API23
format-file24
format-string26
;; Result inspection27
format-result?28
format-result-file29
format-result-success30
format-result-output31
format-result-errors32
format-result-warnings33
format-result-inferences35
;; Output formatting36
format-result->string38
;; Error/warning/inference records39
format-error?40
format-error-line41
format-error-column42
format-error-message43
format-error-code45
format-warning?46
format-warning-line47
format-warning-column48
format-warning-message49
format-warning-code51
paren-inference?52
paren-inference-line53
paren-inference-column54
paren-inference-type55
paren-inference-confidence56
paren-inference-reason58
;; Positioned tokenizer — re-exported from (sigil format tokenize)59
;; so existing (sigil format) consumers keep working unchanged.60
token?61
token-type62
token-value63
token-line64
token-column65
token-indent67
tokenize68
tokenize-result-chars69
tokenize-result-tokens)71
(begin73
;; ============================================================74
;;; Records75
;; ============================================================77
(define-struct format-result78
(file) ; filename79
(success) ; #t if formatting succeeded80
(output) ; formatted code (or #f if failed)81
(errors) ; list of format-error82
(warnings) ; list of format-warning83
(inferences)) ; list of paren-inference85
(define-struct format-error86
(line) ; 1-based line87
(column) ; 1-based column88
(message) ; description89
(code)) ; E001, E002, etc.91
(define-struct format-warning92
(line)93
(column)94
(message)95
(code)) ; W001, W002, etc.97
(define-struct paren-inference98
(line) ; where paren was inferred99
(column)100
(type) ; 'open, 'close, or 'remove101
(confidence) ; 'high, 'medium, 'low102
(reason)) ; explanation string104
;; ============================================================105
;;; Tokens & Tokenizer106
;; ============================================================107
;;108
;; The positioned tokenizer (token type + accessors, `tokenize`, and the109
;; `tokenize-result-*` accessors) lives in the io-free sub-library110
;; (sigil format tokenize). It is imported above and re-exported below so111
;; existing (sigil format) consumers keep working unchanged, while WASM112
;; consumers (e.g. Slate Tier-B highlighting) can import just the113
;; tokenizer without pulling in the formatter or its deps.115
;; ============================================================116
;;; Paren Stack for Tracking117
;; ============================================================119
;; Paren-info layout: #(type line column indent form-type)120
;; Using raw vectors avoids keyword-arg overhead.122
;; ============================================================123
;;; Inference Engine124
;; ============================================================126
;; Zero-allocation form type matching for common keywords.127
;; Only allocates (via token-value + string->symbol) for rare define-* forms.128
(define (get-form-type rest chars)129
(let loop ((ts rest))130
(if (null? ts)131
#f132
(let* ((tok (car ts))133
(tt (vector-ref tok 0)))134
(cond135
((or (eq? tt 'whitespace) (eq? tt 'newline) (eq? tt 'comment))136
(loop (cdr ts)))137
((eq? tt 'symbol)138
(let* ((s (vector-ref tok 1))139
(e (vector-ref tok 2))140
(n (- e s)))141
(cond142
((= n 3) ;; let143
(if (and (char=? (vector-ref chars s) #\l)144
(char=? (vector-ref chars (+ s 1)) #\e)145
(char=? (vector-ref chars (+ s 2)) #\t))146
'let #f))147
((= n 4) ;; let*148
(if (and (char=? (vector-ref chars s) #\l)149
(char=? (vector-ref chars (+ s 1)) #\e)150
(char=? (vector-ref chars (+ s 2)) #\t)151
(char=? (vector-ref chars (+ s 3)) #\*))152
'let* #f))153
((= n 5) ;; begin154
(if (and (char=? (vector-ref chars s) #\b)155
(char=? (vector-ref chars (+ s 1)) #\e)156
(char=? (vector-ref chars (+ s 2)) #\g)157
(char=? (vector-ref chars (+ s 3)) #\i)158
(char=? (vector-ref chars (+ s 4)) #\n))159
'begin #f))160
((= n 6) ;; define or letrec161
(cond162
((and (char=? (vector-ref chars s) #\d)163
(char=? (vector-ref chars (+ s 1)) #\e)164
(char=? (vector-ref chars (+ s 2)) #\f)165
(char=? (vector-ref chars (+ s 3)) #\i)166
(char=? (vector-ref chars (+ s 4)) #\n)167
(char=? (vector-ref chars (+ s 5)) #\e))168
'define)169
((and (char=? (vector-ref chars s) #\l)170
(char=? (vector-ref chars (+ s 1)) #\e)171
(char=? (vector-ref chars (+ s 2)) #\t)172
(char=? (vector-ref chars (+ s 3)) #\r)173
(char=? (vector-ref chars (+ s 4)) #\e)174
(char=? (vector-ref chars (+ s 5)) #\c))175
'letrec)176
(else #f)))177
;; define-syntax (13), define-struct (13), define-library (14)178
((and (> n 6)179
(char=? (vector-ref chars s) #\d)180
(char=? (vector-ref chars (+ s 1)) #\e)181
(char=? (vector-ref chars (+ s 2)) #\f)182
(char=? (vector-ref chars (+ s 3)) #\i)183
(char=? (vector-ref chars (+ s 4)) #\n)184
(char=? (vector-ref chars (+ s 5)) #\e)185
(char=? (vector-ref chars (+ s 6)) #\-))186
(string->symbol (token-value tok chars)))187
(else #f))))188
(else #f))))))190
;;; Analyze tokens and infer missing parens191
(define (analyze-and-infer tokens filename chars)192
;; Mutable analysis state as local variables (avoids struct overhead)193
(let ((paren-stack '())194
(inferences '())195
(errors '())196
(warnings '())197
(depth 0))199
;; Find a paren in stack at given indent level with matching form type200
(define (find-matching-form form-type indent)201
(let loop ((stack paren-stack) (count 0))202
(if (null? stack)203
#f204
(let ((entry (car stack)))205
(if (and (eq? (vector-ref entry 4) form-type)206
(= (vector-ref entry 3) indent))207
count208
(loop (cdr stack) (+ count 1)))))))210
;; Check indent mismatch (form deeper than expected)211
(define (check-indent-mismatch tok form-type)212
(if (and form-type213
(memq form-type '(define define-syntax define-library define-struct214
let let* letrec begin)))215
(let ((tok-indent (vector-ref tok 5))216
(expected-indent (* depth 2)))217
(if (> tok-indent expected-indent)218
(set! warnings219
(cons (format-warning220
line: (vector-ref tok 3)221
column: (vector-ref tok 4)222
message: (format "~a at column ~a is deeper than expected ~a for nesting depth ~a - check if previous form closed too early"223
form-type (+ tok-indent 1) (+ expected-indent 1) depth)224
code: "W001")225
warnings))))))227
;; Check indent decrease (infer closing parens)228
(define (check-indent-decrease tok form-type prev-content-line)229
(if (and form-type230
(memq form-type '(define define-syntax define-struct let let* letrec)))231
(let ((match-count (find-matching-form form-type (vector-ref tok 5))))232
(if match-count233
(let ((close-at-line (or prev-content-line (vector-ref tok 3))))234
(let loop ((to-close (+ match-count 1)))235
(if (and (> to-close 0) (not (null? paren-stack)))236
(let ((top (car paren-stack)))237
(set! inferences238
(cons (paren-inference239
line: close-at-line240
column: 9999241
type: 'close242
confidence: 'high243
reason: (format "New ~a at same level - ~a from line ~a should close"244
form-type245
(if (eq? (vector-ref top 0) 'lparen) "(" "[")246
(vector-ref top 1)))247
inferences))248
(set! paren-stack (cdr paren-stack))249
(set! depth (- depth 1))250
(loop (- to-close 1))))))))))252
;; Push a paren-info onto the stack253
(define (push-paren! type tok form-type)254
(set! paren-stack255
(cons (vector type (vector-ref tok 3) (vector-ref tok 4)256
(vector-ref tok 5) form-type)257
paren-stack))258
(set! depth (+ depth 1)))260
;; Pop a paren from the stack261
(define (pop-paren!)262
(set! paren-stack (cdr paren-stack))263
(set! depth (- depth 1)))265
;; Process each token266
(let loop ((ts tokens) (prev-tok #f) (prev-content-line #f))267
(if (null? ts)268
;; End of tokens - check for unclosed parens269
(begin270
(let close-loop ((stack paren-stack))271
(if (not (null? stack))272
(let* ((top (car stack))273
(top-type (vector-ref top 0))274
(open-char (case top-type275
((lparen) "(")276
((lbracket) "[")277
((hash-lbrace) "#{")278
((hash-lbracket) "#[")279
(else "?"))))280
(set! inferences281
(cons (paren-inference282
line: (or prev-content-line 1)283
column: 9999284
type: top-type285
confidence: 'medium286
reason: (format "End of file - closing ~a from line ~a"287
open-char288
(vector-ref top 1)))289
inferences))290
(close-loop (cdr stack)))))291
;; Return analysis result292
(list (reverse inferences)293
(reverse errors)294
(reverse warnings)))296
(let* ((tok (car ts))297
(rest (cdr ts))298
(ttype (vector-ref tok 0)))300
;; Process token301
(case ttype302
((lparen)303
;; Compute form-type once; check indent only at line start304
(let* ((at-line-start (= (vector-ref tok 4) (+ (vector-ref tok 5) 1)))305
(form-type (if at-line-start (get-form-type rest chars) #f)))306
(when at-line-start307
(check-indent-decrease tok form-type prev-content-line)308
(check-indent-mismatch tok form-type))309
(push-paren! 'lparen tok form-type)))311
((lbracket)312
(push-paren! 'lbracket tok #f))314
((rparen)315
(if (null? paren-stack)316
(set! inferences317
(cons (paren-inference318
line: (vector-ref tok 3)319
column: (vector-ref tok 4)320
type: 'remove321
confidence: 'medium322
reason: "Unexpected closing parenthesis - no matching open")323
inferences))324
(let ((top (car paren-stack)))325
(if (not (eq? (vector-ref top 0) 'lparen))326
(set! errors327
(cons (format-error328
line: (vector-ref tok 3)329
column: (vector-ref tok 4)330
message: (format "Mismatched brackets: [ at line ~a closed with )"331
(vector-ref top 1))332
code: "E003")333
errors))334
(pop-paren!)))))336
((rbracket)337
(if (null? paren-stack)338
(set! inferences339
(cons (paren-inference340
line: (vector-ref tok 3)341
column: (vector-ref tok 4)342
type: 'remove343
confidence: 'medium344
reason: "Unexpected closing bracket - no matching open")345
inferences))346
(let ((top (car paren-stack)))347
(if (not (memq (vector-ref top 0) '(lbracket hash-lbracket)))348
(set! errors349
(cons (format-error350
line: (vector-ref tok 3)351
column: (vector-ref tok 4)352
message: (format "Mismatched brackets: ( at line ~a closed with ]"353
(vector-ref top 1))354
code: "E003")355
errors))356
(pop-paren!)))))358
((hash-lbrace)359
(push-paren! 'hash-lbrace tok #f))361
((hash-lbracket)362
(push-paren! 'hash-lbracket tok #f))364
((rbrace)365
(if (null? paren-stack)366
(set! inferences367
(cons (paren-inference368
line: (vector-ref tok 3)369
column: (vector-ref tok 4)370
type: 'remove371
confidence: 'medium372
reason: "Unexpected closing brace - no matching open")373
inferences))374
(let ((top (car paren-stack)))375
(if (not (eq? (vector-ref top 0) 'hash-lbrace))376
(set! errors377
(cons (format-error378
line: (vector-ref tok 3)379
column: (vector-ref tok 4)380
message: (format "Mismatched: ~a at line ~a closed with }"381
(vector-ref top 0)382
(vector-ref top 1))383
code: "E003")384
errors))385
(pop-paren!))))))387
;; Update prev-content-line if this is a content token388
(loop rest tok389
(if (not (or (eq? ttype 'whitespace) (eq? ttype 'newline)390
(eq? ttype 'comment) (eq? ttype 'eof)))391
(vector-ref tok 3)392
prev-content-line)))))))394
;; ============================================================395
;;; Pretty Printer396
;; ============================================================398
;;; Special forms that have specific indentation rules399
(define special-forms400
'(define define-syntax define-library define-struct401
lambda let let* letrec letrec*402
if cond case when unless403
begin and or404
do loop405
syntax-rules))407
;;; Check if symbol is a special form408
(define (special-form? sym)409
(memq sym special-forms))411
;;; Build map of line -> inferences412
(define (make-inference-map infs)413
(let ((map '()))414
(for-each415
(lambda (inf)416
(let* ((line (paren-inference-line inf))417
(existing (assoc line map)))418
(if existing419
(set-cdr! existing (cons inf (cdr existing)))420
(set! map (cons (cons line (list inf)) map)))))421
infs)422
map))424
;;; Build a set of tokens to remove (by line:column key)425
(define (make-removal-set inferences)426
(filter-map427
(lambda (inf)428
(if (eq? (paren-inference-type inf) 'remove)429
(cons (paren-inference-line inf) (paren-inference-column inf))430
#f))431
inferences))433
;;; Check if a token should be removed434
(define (should-remove? tok removal-set)435
(let ((key (cons (token-line tok) (token-column tok))))436
(member key removal-set)))438
;;; Pretty print tokens to string439
(define (pretty-print-tokens tokens inferences chars)440
;; Reconstruct the source with inferred parens and removals441
(let ((out (open-output-string))442
(inference-map (make-inference-map inferences))443
(removal-set (make-removal-set inferences)))445
;; Check if inference type indicates a close operation446
(define (close-inference-type? type)447
(memq type '(close lparen lbracket hash-lbrace hash-lbracket)))449
;; Get the closing character for an opener type450
(define (closer-for-type type)451
(case type452
((lparen close) #\))453
((lbracket hash-lbracket) #\])454
((hash-lbrace) #\})455
(else #\))))457
;; Get close inferences for a line (column 9999 = end of line)458
(define (get-close-inferences-for-line line)459
(filter (lambda (inf)460
(and (close-inference-type? (paren-inference-type inf))461
(= (paren-inference-line inf) line)))462
inferences))464
;; Insert close parens for a line465
(define (insert-close-parens-for-line line)466
(let ((infs (get-close-inferences-for-line line)))467
(for-each468
(lambda (inf)469
(let ((closer (closer-for-type (paren-inference-type inf))))470
;; Add space before } or ] to avoid creating invalid tokens like "1}"471
(if (memq closer '(#\} #\]))472
(write-char #\space out))473
(write-char closer out)))474
infs)))476
;; Write token value directly from chars vector477
(define (write-token-value tok)478
(let ((s (token-start tok))479
(e (token-end tok)))480
(let loop ((i s))481
(when (< i e)482
(write-char (vector-ref chars i) out)483
(loop (+ i 1))))))485
;; Process tokens486
(let loop ((ts tokens) (last-line 0) (last-content-line 0) (last-inserted 0))487
(if (null? ts)488
(begin489
;; Insert any trailing close parens (only if not already done)490
(if (> last-content-line last-inserted)491
(insert-close-parens-for-line last-content-line))492
(get-output-string out))493
(let ((tok (car ts)))494
(if (eq? (token-type tok) 'eof)495
(begin496
;; Insert any trailing close parens (only if not already done)497
(if (> last-content-line last-inserted)498
(insert-close-parens-for-line last-content-line))499
(get-output-string out))500
(let ((tok-line (token-line tok))501
(is-newline (eq? (token-type tok) 'newline)))502
;; Before adding a newline, insert close parens for content line503
;; But only if we haven't already inserted for this line504
(let ((did-insert (and is-newline505
(> last-content-line 0)506
(> last-content-line last-inserted))))507
(if did-insert508
(insert-close-parens-for-line last-content-line))509
;; Add token value to output (unless marked for removal)510
(if (not (should-remove? tok removal-set))511
(write-token-value tok))512
;; Update last-content-line if not whitespace/newline513
;; Update last-inserted if we just inserted514
(loop (cdr ts)515
tok-line516
(if (memq (token-type tok) '(whitespace newline))517
last-content-line518
tok-line)519
(if did-insert last-content-line last-inserted))))))))))521
;; ============================================================522
;;; Output Formatters523
;; ============================================================525
;;; Format result as human-readable string526
(define (format-result->string result)527
(let ((file (format-result-file result))528
(success (format-result-success result))529
(errors (format-result-errors result))530
(warnings (format-result-warnings result))531
(inferences (format-result-inferences result)))532
(string-append533
(if success534
(format "Formatting ~a...\n" file)535
(format "Errors in ~a:\n" file))536
(if (null? errors)537
""538
(string-append539
(string-join540
(map (lambda (e)541
(format " ~a:~a: error [~a]: ~a"542
file543
(format-error-line e)544
(format-error-code e)545
(format-error-message e)))546
errors)547
"\n")548
"\n\n"))549
(if (null? warnings)550
""551
(string-append552
(string-join553
(map (lambda (w)554
(format " ~a:~a: warning [~a]: ~a"555
file556
(format-warning-line w)557
(format-warning-code w)558
(format-warning-message w)))559
warnings)560
"\n")561
"\n"))562
(if (null? inferences)563
""564
(string-append565
"Inferences:\n"566
(string-join567
(map (lambda (inf)568
(format " line ~a: ~a (~a confidence)\n Reason: ~a"569
(paren-inference-line inf)570
(case (paren-inference-type inf)571
((close lparen) "add ')'")572
((lbracket hash-lbracket) "add ']'")573
((hash-lbrace) "add '}'")574
((open) "add '('")575
((remove) "remove extra paren")576
(else "unknown fix"))577
(paren-inference-confidence inf)578
(paren-inference-reason inf)))579
inferences)580
"\n")581
"\n"))582
(format "\n~a error(s), ~a warning(s), ~a inference(s)\n"583
(length errors)584
(length warnings)585
(length inferences)))))587
;; format-result->json is defined in (sigil format json), which imports588
;; (sigil json). Loaded lazily from the CLI so the bootstrap stdlib589
;; does not require (sigil json).591
;; ============================================================592
;;; Main API593
;; ============================================================595
;;; Try to read a string as Scheme code to verify it's valid596
;;; Returns #t if valid, #f if parse error597
(define (verify-syntax str)598
(guard (exn (else #f))599
(let ((port (open-input-string str)))600
(let loop ()601
(let ((expr (read port)))602
(if (eof-object? expr)603
#t604
(loop)))))))606
;;; Format a string of source code607
(define (format-string source filename)608
(let* ((result (tokenize source filename))609
(chars (car result))610
(tokens (cdr result))611
(analysis (analyze-and-infer tokens filename chars))612
(inferences (car analysis))613
(errors (cadr analysis))614
(warnings (caddr analysis))615
;; Generate output if we have inferences to apply AND no hard errors616
;; Don't generate output if there are errors (like mismatched brackets)617
;; since verify-syntax can't catch reader errors618
(has-fixes (not (null? inferences)))619
(output (if (and (null? errors) has-fixes)620
(pretty-print-tokens tokens inferences chars)621
(if (null? errors)622
source ; No changes needed623
#f))) ; Has errors, can't fix624
;; Verify the output parses correctly (only if we generated output)625
(verified (if output (verify-syntax output) #f))626
;; Success if: no errors AND (no inferences OR verified output)627
(success (and (null? errors)628
(or (null? inferences) verified))))629
(format-result630
file: filename631
success: success632
output: (if verified output #f)633
errors: (if (and output (not verified))634
;; Add verification error if output didn't parse635
(cons (format-error636
line: 1637
column: 1638
message: "Auto-fix produced invalid syntax - manual review needed"639
code: "E099")640
errors)641
errors)642
warnings: warnings643
inferences: inferences)))645
;;; Format a file646
(define (format-file path)647
(let ((source (call-with-input-file path648
(lambda (port)649
(read-string (file-size path) port)))))650
(format-string source path)))))