#lang at-exp racket/base

(require racket/contract
         racket/file
         racket/format
         racket/function
         racket/list
         racket/match
         syntax/modread
         racket/path
         syntax/parse
         syntax/strip-context
         syntax/stx
         (only-in "error.rkt" display-exn)
         "mod.rkt"
         (only-in "util.rkt" display-commented))

(provide dynamic-require/some-namespace)

;; A composition of dynamic-require and module->namespace that tries
;; to tolerate syntax errors. It tries to return a namespace with at
;; least some identifiers from the file -- such as from module
;; languages, requires, and definitions.
;;
;; Motivation:
;;
;;   https://github.com/greghendershott/racket-mode/issues/272
;;
;; You're working in #lang racket/base. You're partway through writing
;; a some expression, and realize you need to add (say)
;; with-module-reading-parameterization. You add syntax/modread to
;; your require.
;;
;; Now, you want to type with-m and hit TAB to complete. Plus after
;; that, you might want to C-. a.k.a. M-x racket-describe to read
;; docs.
;;
;; But you need to re-run, first, for the new require to take effect
;; and make the syntax/modread exports available.
;;
;; But if you re-run, your half-written expression results in a syntax
;; or runtime error. Now your REPL is just an empty racket/base.
;;
;; Annoying!
;;
;; Strategy: When dynamic-require fails, try again using a custom load
;; handler that rewrites the file -- "distill" it to a skeleton of
;; module forms, requires, and define-values. Try again using that.
;;
;; Note that it's important for the skeleton to include submodules,
;; because racket-mode lets you "enter" a submodule and work with
;; identifiers inside it (and only inside it).

(define is-skeleton
  "[Due to errors, REPL is just module language, requires, and stub definitions]")
(define is-base
  "[Due to errors, REPL is just racket/base]")

;; A composition of dynamic-require and module->namespace, but which
;; tries to tolerate errors in the source file and return _some_
;; namespace more useful than racket/base (if possible).
(define/contract (dynamic-require/some-namespace mod retry-as-skeletal?)
  (-> mod? boolean? namespace?)
  (parameterize ([current-load-relative-directory (mod-dir mod)]
                 [current-directory               (mod-dir mod)])
    (cond [(normal mod) => values]
          [(and retry-as-skeletal?
                (skeletal mod)) => (λ (ns)
                                     (display-commented is-skeleton)
                                     ns)]
          [else (display-commented is-base)
                (make-base-namespace)])))

(define/contract (normal mod)
  (-> mod? (or/c #f namespace?))
  (with-handlers ([exn:fail? (λ (e) (display-exn e) #f)])
    (dynamic-require (mod-rmp mod) #f)
    (module->namespace (mod-rmp mod))))

(define/contract (skeletal mod)
  (-> mod? (or/c #f namespace?))
  (with-handlers ([exn:fail? (const #f)]) ;don't show errors again
    (parameterize ([current-load      (make-load mod)]
                   ;; Module is cached in old namespace, so for `load`
                   ;; to be called, we need a fresh namespace.
                   [current-namespace (make-base-namespace)])
      (dynamic-require (mod-rmp mod) #f)
      (module->namespace (mod-rmp mod)))))

(define/contract (make-load mod)
  (-> mod? any)
  (define original-load (current-load))
  (define special-path (build-path (mod-dir mod) (mod-file mod)))
  (λ (path module-name)
    (if (equal? path special-path)
        (eval (skeleton (read-module-file path)))
        (original-load path module-name))))

(define (read-module-file file) ;Path-String -> Syntax
  (with-module-reading-parameterization
    (λ ()
      (parameterize ([read-accept-compiled #f])
        (with-input-from-file file read-syntax)))))

(define no-op-expr    #'(void))
(define no-op-def-val #''|Due to errors in source file, this value is from a "stub" define-values|)

(define (skeleton stx) ;Syntax -> Syntax
  ;; We got here because `stx` has either a syntax error or a runtime
  ;; error. If it has a syntax error, we can't `expand` it as whole.
  ;; Let's try to distill it to a skeleton of things that create
  ;; runtime, module-level bidings: requires and defines.
  ;;
  ;; To get #%require and define-values, we want to work with
  ;; fully-expanded syntax as much as possible. But we have to catch
  ;; syntax errors and replace each with #'(void). Also we want to
  ;; walk submodule forms for their bindings, but we can't expand a
  ;; submodule forms in isolation (that's a syntax error).
  ;;
  ;; So, the idea is to preserve the nested modules skeleton, and only
  ;; try to expand each of their module-level expressions to discover
  ;; bindings.
  ;;
  ;; Our final result should, as a whole, work with (eval (expand)).
  (strip-context
   ;; Unlike expand-syntax-to-top-form, expand-to-top-form does
   ;; namespace-syntax-introduce before expanding to top form.
   (let recur ([stx (expand-to-top-form stx)])
     (syntax-parse stx
       #:literal-sets   (kernel-literals)
       #:datum-literals (#%module-begin module+)
       ;; Note: A #lang file has #%module-begin even on initial read
       ;; and without calling `expand`. However, a (module) expression
       ;; file -- even when using with-module-reading-parameterization
       ;; -- doesn't. That only gets added by `expand`. But we can't
       ;; use `expand`. Anyway, it hardly matters as we're going to
       ;; remove everything interesting that a #%module-begin might
       ;; transform (IIUC). Just treat #%module-begin as begin.
       [((~and mod (~or module module*)) name:id lang:expr . es)
        #`(mod name lang . #,(stx-map recur #'es))]
       [(#%module-begin . es)
        #`(begin . #,(stx-map recur #'es))]
       [(module+ name:id . es)
        #`(module+ name . #,(stx-map recur #'es))]
       [_
        (let ([stx (with-handlers ([exn:fail:syntax? (const no-op-expr)])
                     (expand stx))])
          (syntax-parse stx
            #:literal-sets (kernel-literals)
            [(begin . es)                 #`(begin . #,(stx-map recur #'es))]
            [(#%require . _)              stx]
            [(define-values (id ...) . _) #`(define-values (id ...)
                                              (values
                                               #,@(stx-map (const no-op-def-val)
                                                           #'(id ...))))]
            [_                            no-op-expr]))]))))

(module+ test
  (require rackunit
           racket/set
           version/utils)

  ;; A example of the transformation we do.
  ;;
  ;; Note: Prior to Racket 6.3, expansion of `require` with
  ;; non-existent modules seems to be a syntax error. So in this test,
  ;; use modules that actually exist in minimal Racket.
  (check-equal? (syntax->datum
                 (skeleton
                  #'(module m racket/base
                      (#%module-begin
                       (require racket/pretty
                                racket/list)
                       (if)      ;stx err
                       (/ 1 0)   ;runtime err
                       (define foo 42)
                       (define-values (bar baz) (values 43 44))
                       (define (f x) (+ x 1))
                       (module* m #f
                         (require net/url)
                         (if) ;stx err
                         (/ 1 0)) ;runtime err
                       (module+ test
                         (require rackunit)
                         (if)) ;stx err
                       (module m typed/racket/base
                         (#%module-begin
                          (require racket/function)
                          (define id 42)
                          (if))))))) ;stx err
                (let ([no-op-expr    (syntax->datum no-op-expr)]
                      [no-op-def-val (syntax->datum no-op-def-val)])
                  `(module m racket/base
                    (begin
                     (begin (#%require racket/pretty) (#%require racket/list))
                     ,no-op-expr
                     ,no-op-expr
                     (define-values (foo) (values ,no-op-def-val))
                     (define-values (bar baz) (values ,no-op-def-val ,no-op-def-val))
                     (define-values (f) (values ,no-op-def-val))
                     (module* m #f
                      (#%require net/url)
                      (void)
                      (void))
                     (module+ test
                      (#%require rackunit)
                      ,no-op-expr)
                     (module m typed/racket/base
                      (begin
                        (#%require racket/function)
                        (define-values (id) (values ,no-op-def-val))
                        ,no-op-expr))))))

  ;; Helpers to write text or sexpr to a tempory .rkt file, then run
  ;; through dynamic-require/some-namespace and get the
  ;; namespace-mapped-symbols.

  (define/contract (call-with-temporary-file v proc)
    (-> any/c (-> mod? any/c) any/c)
    (define file #f)
    (dynamic-wind
      (λ ()
        (set! file (make-temporary-file "call-with-temporary-file-~a.rkt"))
        (call-with-output-file file #:exists 'replace
          (λ (out)
            (cond [(string? v) (display v out)]
                  [else        (write v out)]))))
      (λ () (proc (->mod/existing file)))
      (λ () (delete-file file))))

  (define/contract (syms mod)
    (-> mod? (listof symbol?))
    (namespace-mapped-symbols
     (parameterize ([current-namespace (make-base-empty-namespace)])
       (dynamic-require/some-namespace mod #t))))

  (define (do v)
    (define op (open-output-string))
    (define result (parameterize ([current-error-port op])
                     (call-with-temporary-file v syms)))
    (check-match (get-output-string op)
                 (regexp (string-append (regexp-quote is-skeleton) "\n$")))
    result)

  ;; Despite a syntax error and a runtime error, a binding provided by
  ;; a require is available in the namespace in both:

  ;; (a) A #lang file:
  (check-not-false
   (memq 'pretty-print (do @~a{#lang racket/base
                               (if)
                               (require racket/pretty)})))

  ;; (b) A module expression file:
  (check-not-false
   (memq 'pretty-print (do `(module m racket/base
                             (if)
                             (require racket/pretty)))))

  ;; Requiring exactly 1 binding adds exactly that symbol to the
  ;; namespace:
  (check-equal? (set-subtract
                 (list->set
                  (do `(module m racket/base
                        (/ 1 0)
                        (require (only-in racket/pretty pretty-print)))))
                 (list->set
                  (do `(module n racket/base
                        (/ 1 0)))))
                (set 'pretty-print)))
