diff --git a/changes/typecheck-command.feature.md b/changes/typecheck-command.feature.md new file mode 100644 index 0000000000..288103ce82 --- /dev/null +++ b/changes/typecheck-command.feature.md @@ -0,0 +1 @@ +- Add `rescript typecheck ` for editors, which type-checks a file, or its unsaved contents from stdin with `--stdin`, using the compiler built into `rescript`, and prints the diagnostics. diff --git a/rewatch-ocaml/README.md b/rewatch-ocaml/README.md index e3f25b27ea..f9ff97d2dd 100644 --- a/rewatch-ocaml/README.md +++ b/rewatch-ocaml/README.md @@ -1,7 +1,7 @@ # Rewatch This directory contains ReScript's build system and CLI (`rescript build`, -`watch`, `clean`, `format`, and `compiler-args`). It ships as `rescript` in the +`watch`, `clean`, `format`, `compiler-args`, and `typecheck`). It ships as `rescript` in the `@rescript/` packages and is tested with the integration suite in [`../rewatch/tests`](../rewatch/tests). @@ -51,7 +51,7 @@ does not depend on the runner's libc. The version lives in | Processes | `process` (worker pool), `process_child` (one child and its pipes), `after_build` | | Platform | `platform.mli` implemented by `platform_unix.ml` or `platform_windows.ml` (selected by dune), `windows_job_stubs.c`, `termination_signals`, `build_lock` | | Watching | `watcher` (rebuild loop), `watch_scope` (what to watch), `watch_snapshot` (filesystem snapshots), `native_watcher` (libuv notifications) | -| Other commands | `format`, `compiler_args_command` | +| Other commands | `format`, `compiler_args_command`, `typecheck_command` (editor type checks of unsaved contents) | Native filesystem notifications only wake the watcher. The snapshot taken afterwards decides what changed, so merged, reordered, or dropped events can't @@ -125,8 +125,14 @@ under the process owners described below. - A failing `--after-build` command fails the build. On Unix, hooks inherit redirected stdin but not an interactive terminal, because they run in their own process group. -- `format -v` and `compiler-args -v` write their logs to stderr, so stdout - remains formatted source or JSON. +- `format -v`, `compiler-args -v` and `typecheck -v` write their logs to + stderr, so stdout remains formatted source, JSON or diagnostics. +- `typecheck` compiles a copy of the source (or of stdin with `--stdin`) in + `lib/rescript/incremental`, where editor analysis reads its typed tree. It + writes no JavaScript or genType output and leaves the build's artifacts + alone. Its diagnostics name the source instead of the copy, and a parse error + doesn't stop the type check. With `--across-files`, the interfaces of earlier + type checks there take precedence over the build's. - While the root configuration is invalid, only configuration changes trigger recovery attempts in watch mode. - Watch mode notices a missing dependency being installed into an existing diff --git a/rewatch-ocaml/cli.ml b/rewatch-ocaml/cli.ml index e5b148af3d..a68fa21f2a 100644 --- a/rewatch-ocaml/cli.ml +++ b/rewatch-ocaml/cli.ml @@ -4,6 +4,7 @@ type command = | Watch of build_options | Format of format_input | Compiler_args of {verbosity: int; path: string} + | Typecheck of {verbosity: int; stdin: bool; across_files: bool; path: string} and build_options = { verbosity: int; @@ -207,6 +208,30 @@ let compiler_args_term = let+ verbosity = verbosity and+ path in Compiler_args {verbosity; path} +let typecheck_term = + let stdin = + Arg.( + value & flag + & info ["stdin"] + ~doc:"Type-check the contents read from stdin in place of PATH's.") + in + let across_files = + Arg.( + value & flag + & info ["across-files"] + ~doc: + "Use the interfaces of earlier type checks of other files, so that \ + unsaved changes in one file are seen in the others.") + in + let path = + Arg.( + required + & pos 0 (some string) None + & info [] ~docv:"PATH" ~doc:"ReScript source file (.res or .resi).") + in + let+ verbosity = verbosity and+ stdin and+ across_files and+ path in + Typecheck {verbosity; stdin; across_files; path} + let exits = [ Cmd.Exit.info 0 ~doc:"on success."; @@ -242,6 +267,13 @@ let root = "Print the compiler arguments for a ReScript source file.") compiler_args_term in + let typecheck = + Cmd.make + (command_info "typecheck" + "Type-check a ReScript source file for an editor and print its \ + diagnostics.") + typecheck_term + in let help = let topic = Arg.(value & pos 0 (some string) None & info [] ~docv:"COMMAND") @@ -277,7 +309,7 @@ let root = ~doc:"Fast, Simple, Fully Typed JavaScript from the Future" ~man in Cmd.group info ~default:(build_term ~watch:false) - [build; watch; clean; format; compiler_args; help] + [build; watch; clean; format; compiler_args; typecheck; help] type evaluation = Run of command | Exit of int @@ -321,7 +353,9 @@ let normalize_argv argv = argument = "--version" || short_cluster_contains 'V' argument in let is_command = function - | "build" | "watch" | "clean" | "format" | "compiler-args" | "help" -> true + | "build" | "watch" | "clean" | "format" | "compiler-args" | "typecheck" + | "help" -> + true | _ -> false in let rec normalize_display_options = function diff --git a/rewatch-ocaml/cli.mli b/rewatch-ocaml/cli.mli index ceb134500d..aec3f2df08 100644 --- a/rewatch-ocaml/cli.mli +++ b/rewatch-ocaml/cli.mli @@ -4,6 +4,7 @@ type command = | Watch of build_options | Format of format_input | Compiler_args of {verbosity: int; path: string} + | Typecheck of {verbosity: int; stdin: bool; across_files: bool; path: string} and build_options = { verbosity: int; diff --git a/rewatch-ocaml/compiler_args.ml b/rewatch-ocaml/compiler_args.ml index dbc16c555a..9e36d24924 100644 --- a/rewatch-ocaml/compiler_args.ml +++ b/rewatch-ocaml/compiler_args.ml @@ -31,25 +31,26 @@ let source_map_args (config : Config.t) ~watch = if config.source_map_dev && not watch then ["-bs-source-map"; "false"] else config.source_map_args +let ppx_arguments (config : Config.t) ppx_flags = + ppx_flags + |> List.concat_map (function + | [] -> [] + | flag :: arguments -> + let executable = + (* A path starting with a dot names a file in the package itself; + bsc runs the PPX from lib/rescript. *) + if String.starts_with ~prefix:"." flag then + Filename.concat config.root flag + else + match Project_context.dependency_path config.root flag with + | Some path -> path + | None -> flag + in + ["-ppx"; String.concat " " (executable :: arguments)]) + let parser_flags ?(ppx_flags = []) (config : Config.t) = - let ppx_args = - ppx_flags - |> List.concat_map (function - | [] -> [] - | flag :: arguments -> - let executable = - (* A path starting with a dot names a file in the package itself; - bsc runs the PPX from lib/rescript. *) - if String.starts_with ~prefix:"." flag then - Filename.concat config.root flag - else - match Project_context.dependency_path config.root flag with - | Some path -> path - | None -> flag - in - ["-ppx"; String.concat " " (executable :: arguments)]) - in - ppx_args @ config.jsx_args @ config.experimental_args @ config.warning_flags + ppx_arguments config ppx_flags + @ config.jsx_args @ config.experimental_args @ config.warning_flags @ config.compiler_flags let compiler_flags ~watch (config : Config.t) = @@ -111,13 +112,14 @@ let compiler_common_arguments ~project_root ~(config : Config.t) ~runtime @ gentype_dependency_args @ ["-bs-package-name"; config.name; "-bs-project-root"; project_root] +let interface_arguments ~source_kind ~has_interface = + match source_kind with + | Source.Implementation when has_interface -> ["-bs-read-cmi"] + | Source.Implementation | Source.Interface -> [] + let compiler_arguments_with_common ~(config : Config.t) ~common_args ~module_name ~source_kind ~has_interface ~path = - let interface_args = - match source_kind with - | Source.Implementation when has_interface -> ["-bs-read-cmi"] - | Source.Implementation | Source.Interface -> [] - in + let interface_args = interface_arguments ~source_kind ~has_interface in let output_args = match source_kind with | Source.Interface -> [] @@ -138,3 +140,21 @@ let compiler_arguments ~project_root ~(config : Config.t) ~runtime (compiler_common_arguments ~project_root ~config ~runtime ~dependency_dirs ~watch ~gentype_dependency_args) ~module_name ~source_kind ~has_interface ~path + +(* The arguments of an editor type check of [input], a copy of the source + with the editor's unsaved contents. It runs the parse and the compile in one + request and writes no JavaScript and no genType output: only the interface + and typed tree next to [input], which editor analysis reads. A parse error + does not stop the type check, so the rest of the file still gets + diagnostics. *) +let typecheck_arguments ~project_root ~(config : Config.t) ~runtime + ~dependency_dirs ~module_name ~source_kind ~has_interface ~contents + ~include_dirs ~input = + let config = {config with gentype_args = []} in + List.concat_map (fun directory -> ["-I"; directory]) include_dirs + @ namespace_args config module_name + @ interface_arguments ~source_kind ~has_interface + @ compiler_common_arguments ~project_root ~config ~runtime ~dependency_dirs + ~watch:false ~gentype_dependency_args:[] + @ ppx_arguments config (filter_ppx_flags config.ppx_flags contents) + @ ["-color"; "never"; "-ignore-parse-errors"; input] diff --git a/rewatch-ocaml/compiler_args.mli b/rewatch-ocaml/compiler_args.mli index e70b49c81d..a9c3e6d5f6 100644 --- a/rewatch-ocaml/compiler_args.mli +++ b/rewatch-ocaml/compiler_args.mli @@ -46,3 +46,19 @@ val compiler_arguments_with_common : has_interface:bool -> path:string -> string list + +val typecheck_arguments : + project_root:string -> + config:Config.t -> + runtime:string -> + dependency_dirs:string list -> + module_name:string -> + source_kind:Source.source_kind -> + has_interface:bool -> + contents:string -> + include_dirs:string list -> + input:string -> + string list +(** The arguments that type-check [input], a copy of a source file with + [contents], for an editor: no JavaScript or genType output, and + [include_dirs] searched before the build's interfaces. *) diff --git a/rewatch-ocaml/compiler_args_command.ml b/rewatch-ocaml/compiler_args_command.ml index ff84a7a5b2..49274468e2 100644 --- a/rewatch-ocaml/compiler_args_command.ml +++ b/rewatch-ocaml/compiler_args_command.ml @@ -32,7 +32,19 @@ let lexical_absolute path = if Sys.win32 then String.map (fun c -> if c = '/' then '\\' else c) path else path -let run ~verbosity path = +type context = { + config: Config.t; + source: string; + relative: string; + contents: string; + source_kind: Source.source_kind; + has_interface: bool; + dependency_dirs: string list; + runtime: string; + project_root: string; +} + +let context ~verbosity ~command ?contents path = let source = try Filename.concat @@ -47,7 +59,7 @@ let run ~verbosity path = not (Filename.check_suffix source ".res" || Filename.check_suffix source ".resi") - then error "compiler-args expects a .res or .resi source file"; + then error (command ^ " expects a .res or .resi source file"); let package_config = match Project_context.nearest_config_path (Filename.dirname source) with | Some path -> Config.load path @@ -63,13 +75,13 @@ let run ~verbosity path = let config = Config.with_root_options package_config root_config in let relative = Project_context.relative_to config.root source in let contents = - try File_util.read_file source with - | Sys_error message -> source_error path message - | Unix.Unix_error (unix_error, _, _) -> - source_error path (Unix.error_message unix_error) - in - let parser_args = - Compiler_args.parser_arguments ~config ~contents ~path:relative + match contents with + | Some contents -> contents + | None -> ( + try File_util.read_file source with + | Sys_error message -> source_error path message + | Unix.Unix_error (unix_error, _, _) -> + source_error path (Unix.error_message unix_error)) in let source_kind = if Filename.check_suffix source ".resi" then Source.Interface @@ -109,6 +121,35 @@ let run ~verbosity path = (Filename.dirname (lexical_absolute path)) |> Option.fold ~none:config.root ~some:Filename.dirname in + { + config; + source; + relative; + contents; + source_kind; + has_interface; + dependency_dirs; + runtime; + project_root; + } + +let run ~verbosity path = + let { + config; + relative; + contents; + source_kind; + has_interface; + dependency_dirs; + runtime; + project_root; + source; + } = + context ~verbosity ~command:"compiler-args" path + in + let parser_args = + Compiler_args.parser_arguments ~config ~contents ~path:relative + in let compiler_args = Compiler_args.compiler_arguments ~project_root ~config ~runtime ~dependency_dirs diff --git a/rewatch-ocaml/compiler_args_command.mli b/rewatch-ocaml/compiler_args_command.mli index 6d485a8e99..2aec3aa019 100644 --- a/rewatch-ocaml/compiler_args_command.mli +++ b/rewatch-ocaml/compiler_args_command.mli @@ -1 +1,21 @@ +type context = { + config: Config.t; (** The configuration of the source's package. *) + source: string; (** The source, with its directory canonicalized. *) + relative: string; (** The source relative to [config.root]. *) + contents: string; + source_kind: Source.source_kind; + has_interface: bool; + dependency_dirs: string list; + (** The artifact directories of the package's dependencies. *) + runtime: string; + project_root: string; + (** The project root spelled the way the caller named the source. *) +} + +val context : + verbosity:int -> command:string -> ?contents:string -> string -> context +(** The compile context of a source file. [contents] replaces the file's + contents, which are read otherwise. [command] names the command in + errors. *) + val run : verbosity:int -> string -> string diff --git a/rewatch-ocaml/dune b/rewatch-ocaml/dune index 82bc56d69d..48ca29358e 100644 --- a/rewatch-ocaml/dune +++ b/rewatch-ocaml/dune @@ -55,6 +55,7 @@ package_graph compiler_args compiler_args_command + typecheck_command compiler_log ast_header compiler_execution_mode diff --git a/rewatch-ocaml/format.mli b/rewatch-ocaml/format.mli index 3cb8845e0a..fa1853e5f2 100644 --- a/rewatch-ocaml/format.mli +++ b/rewatch-ocaml/format.mli @@ -6,6 +6,9 @@ val format_check_summary : int -> string val format_files : ?poll:(unit -> unit) -> check:bool -> string list -> unit +val read_stdin_interruptibly : ?poll:(unit -> unit) -> unit -> string +(** Reads all of stdin, calling [poll] while waiting. *) + val format_stdin : ?poll:(unit -> unit) -> string -> unit val run_files : ?poll:(unit -> unit) -> verbosity:int -> check:bool -> string list -> unit diff --git a/rewatch-ocaml/rescript_ocaml.ml b/rewatch-ocaml/rescript_ocaml.ml index 0c7b37f3f5..c03576420d 100644 --- a/rewatch-ocaml/rescript_ocaml.ml +++ b/rewatch-ocaml/rescript_ocaml.ml @@ -56,6 +56,9 @@ let run_command ~poll = function Format.run_files ~poll ~verbosity ~check paths | Cli.Compiler_args {verbosity; path} -> print_endline (Compiler_args_command.run ~verbosity path) + | Cli.Typecheck {verbosity; stdin; across_files; path} -> + if not (Typecheck_command.run ~poll ~verbosity ~stdin ~across_files path) + then exit 1 | Cli.Clean {verbosity; folder; prod} -> Clean.run ~poll ~verbosity ~folder ~prod diff --git a/rewatch-ocaml/tests/run.sh b/rewatch-ocaml/tests/run.sh index 4e705fd049..6186650a2d 100644 --- a/rewatch-ocaml/tests/run.sh +++ b/rewatch-ocaml/tests/run.sh @@ -998,6 +998,54 @@ printf 'let value = Lower.hidden\n' > "$basic/src/LowerConsumer.res" grep 'hidden' "$basic/src/LowerConsumer.mjs" >/dev/null test ! -f "$basic/lib/rescript/lower.cmti" +# typecheck compiles a copy of the source in lib/rescript/incremental with the +# embedded compiler, for editors, and leaves the build's artifacts alone. +cp "$basic/lib/rescript/B.cmt" "$work/typecheck-B.cmt" +"$port" typecheck "$basic/src/B.res" >"$work/typecheck-clean.out" +test ! -s "$work/typecheck-clean.out" +test -f "$basic/lib/rescript/incremental/B.cmt" +if printf 'let answer = A.value + "x"\nlet broken =\n' | + "$port" typecheck --stdin "$basic/src/B.res" >"$work/typecheck-error.out"; then + echo "typecheck accepted a type error" >&2 + exit 1 +fi +# Diagnostics name the source, not its copy, and a parse error doesn't hide +# the type error before it. +tr '\\' '/' <"$work/typecheck-error.out" | grep -F "basic/src/B.res:1:24" >/dev/null +grep 'This let-binding misses an expression' "$work/typecheck-error.out" >/dev/null +if grep 'incremental' "$work/typecheck-error.out" >/dev/null; then + echo "typecheck diagnostics named the incremental copy" >&2 + exit 1 +fi +printf 'let answer = A.value + 1\n' | cmp - "$basic/src/B.res" +cmp "$work/typecheck-B.cmt" "$basic/lib/rescript/B.cmt" +if ls "$basic/lib/rescript/incremental" | grep -v -E '\.(res|cmi|cmj|cmt)$'; then + echo "typecheck wrote unexpected output" >&2 + exit 1 +fi +# The build's interface still constrains the implementation. +if printf 'let value = "s"\n' | + "$port" typecheck --stdin "$basic/src/WithInterface.res" \ + >"$work/typecheck-interface.out"; then + echo "typecheck ignored the interface" >&2 + exit 1 +fi +grep 'does not match the interface' "$work/typecheck-interface.out" >/dev/null +# With --across-files, a dependent sees the unsaved change of its dependency. +printf 'let value = "s"\n' | "$port" typecheck --stdin "$basic/src/A.res" +"$port" typecheck "$basic/src/B.res" >/dev/null +if "$port" typecheck --across-files "$basic/src/B.res" >/dev/null; then + echo "typecheck --across-files ignored the unsaved dependency" >&2 + exit 1 +fi +if "$port" typecheck "$basic/src/A.txt" 2>"$work/typecheck-extension.err"; then + echo "typecheck accepted a non-ReScript file" >&2 + exit 1 +fi +grep 'typecheck expects a .res or .resi source file' \ + "$work/typecheck-extension.err" >/dev/null +rm -r "$basic/lib/rescript/incremental" + "$port" clean "$basic" test ! -f "$basic/src/A.mjs" diff --git a/rewatch-ocaml/typecheck_command.ml b/rewatch-ocaml/typecheck_command.ml new file mode 100644 index 0000000000..d84366a9d8 --- /dev/null +++ b/rewatch-ocaml/typecheck_command.ml @@ -0,0 +1,58 @@ +(* Editors type-check unsaved contents of a source file. The contents are + written to a copy in lib/rescript/incremental, where editor analysis looks + for the typed tree of the copy, and the copy is compiled with the build's + arguments by the embedded compiler. *) + +let incremental_dir_name = "incremental" + +let replace_all ~pattern ~with_ text = + if pattern = "" then text + else Str.global_replace (Str.regexp_string pattern) with_ text + +let run ?poll ~verbosity ~stdin ~across_files path = + let contents = + if stdin then Some (Format.read_stdin_interruptibly ?poll ()) else None + in + let ({ + config; + source; + relative; + contents; + source_kind; + has_interface; + dependency_dirs; + runtime; + project_root; + } + : Compiler_args_command.context) = + Compiler_args_command.context ~verbosity ~command:"typecheck" ?contents path + in + let artifact_dir = Build_artifacts.artifact_dir config.root in + File_util.ensure_dir (Filename.concat artifact_dir incremental_dir_name); + (* Spelled from the caller's project root, like the source in the + diagnostics, so that the copy's path can be mapped back to the source. *) + let input = + Filename.concat + (Filename.concat + (Build_artifacts.artifact_dir project_root) + incremental_dir_name) + (Filename.basename source) + in + File_util.write_file input contents; + let args = + Compiler_args.typecheck_arguments ~project_root ~config ~runtime + ~dependency_dirs + ~module_name:(Source.module_name source) + ~source_kind ~has_interface ~contents + ~include_dirs:(if across_files then [incremental_dir_name] else []) + ~input + in + Output.debug ~channel:stderr ~verbosity + ("Type-checking with: " ^ String.concat " " args); + let result = Compiler_process.run ?poll {args; cwd = artifact_dir} in + (* Diagnostics name the source, not its copy. *) + print_string + (replace_all ~pattern:input + ~with_:(Filename.concat project_root relative) + (result.stdout ^ result.stderr)); + Process.succeeded result diff --git a/rewatch-ocaml/typecheck_command.mli b/rewatch-ocaml/typecheck_command.mli new file mode 100644 index 0000000000..714a32680f --- /dev/null +++ b/rewatch-ocaml/typecheck_command.mli @@ -0,0 +1,13 @@ +val run : + ?poll:(unit -> unit) -> + verbosity:int -> + stdin:bool -> + across_files:bool -> + string -> + bool +(** Type-checks a source file, or the contents read from stdin in its place, + and prints the compiler's diagnostics for it. Returns whether the type + check succeeded. The interface and typed tree of the check are written to + lib/rescript/incremental for editor analysis; build artifacts are not + changed. With [across_files], interfaces from earlier type checks in that + directory take precedence over the build's. *) diff --git a/tests/build_tests/cli_help/input.js b/tests/build_tests/cli_help/input.js index d538968cfd..4a0da7a91a 100755 --- a/tests/build_tests/cli_help/input.js +++ b/tests/build_tests/cli_help/input.js @@ -37,6 +37,10 @@ const cliHelp = " help [OPTION]… [COMMAND]\n" + " Print this message or command help.\n" + "\n" + + " typecheck [OPTION]… PATH\n" + + " Type-check a ReScript source file for an editor and print its\n" + + " diagnostics.\n" + + "\n" + " watch [OPTION]… [FOLDER]\n" + " Build, then start a watcher.\n" + "\n" +