diff --git a/compiler/bsc/rescript_compiler_main.ml b/compiler/bsc/rescript_compiler_main.ml index ec40263bb6..6625b0709a 100644 --- a/compiler/bsc/rescript_compiler_main.ml +++ b/compiler/bsc/rescript_compiler_main.ml @@ -52,6 +52,8 @@ let setup_outcome_printer () = Lazy.force Res_outcome_printer.setup let setup_runtime_path path = Runtime_package.path := path +let print_suggested_actions_if_any () = Suggested_actions.print_block_if_any () + let process_file sourcefile ?kind ppf = (* This is a better default then "", it will be changed later The {!Location.input_name} relies on that we write the binary ast @@ -114,6 +116,7 @@ let reprint_source_file sourcefile = if parse_result.invalid then ( Res_diagnostics.print_report parse_result.diagnostics parse_result.source; + print_suggested_actions_if_any (); exit 1); Res_compmisc.init_path (); parse_result.parsetree @@ -131,6 +134,7 @@ let reprint_source_file sourcefile = if parse_result.invalid then ( Res_diagnostics.print_report parse_result.diagnostics parse_result.source; + print_suggested_actions_if_any (); exit 1); Res_compmisc.init_path (); parse_result.parsetree @@ -143,6 +147,7 @@ let reprint_source_file sourcefile = print_endline ("Invalid input for reprinting ReScript source. Must be a ReScript \ file: " ^ sourcefile); + print_suggested_actions_if_any (); exit 2 in res @@ -198,6 +203,7 @@ let bs_version_string = "ReScript " ^ Bs_version.version let print_version_string () = print_endline bs_version_string; + print_suggested_actions_if_any (); exit 0 let[@inline] set s : Bsc_args.spec = Unit (Unit_set s) @@ -358,6 +364,14 @@ let command_line_flags : (string * Bsc_args.spec * string) array = Clflags.ignore_parse_errors := true; Js_config.cmi_only := true), "*internal* Enable editor mode." ); + ( "-llm-mode", + unit_call (fun () -> + Js_config.llm_mode := true; + Clflags.binary_annotations := false; + Clflags.color := Some Never; + Location.draw_underline_in_code_frame := true; + Warnings.llm_mode := true), + "*internal* Enable LLM mode for enhanced tooling" ); ( "-ignore-parse-errors", set Clflags.ignore_parse_errors, "*internal* continue after parse errors" ); @@ -443,7 +457,11 @@ let _ : unit = try Bsc_args.parse_exn ~argv:Sys.argv command_line_flags anonymous ~usage with | Bsc_args.Bad msg -> Format.eprintf "%s@." msg; + print_suggested_actions_if_any (); exit 2 | x -> Location.report_exception ppf x; + print_suggested_actions_if_any (); exit 2 + +let _ = print_suggested_actions_if_any () diff --git a/compiler/common/js_config.ml b/compiler/common/js_config.ml index 24aa8b69f1..c3037e0b6e 100644 --- a/compiler/common/js_config.ml +++ b/compiler/common/js_config.ml @@ -72,3 +72,5 @@ let jsx_module_of_string = function let as_pp = ref false let self_stack : string Stack.t = Stack.create () + +let llm_mode = ref false diff --git a/compiler/common/js_config.mli b/compiler/common/js_config.mli index d6f4bd8ba6..9d7554d3c2 100644 --- a/compiler/common/js_config.mli +++ b/compiler/common/js_config.mli @@ -101,3 +101,5 @@ val jsx_module_of_string : string -> jsx_module val as_pp : bool ref val self_stack : string Stack.t + +val llm_mode : bool ref diff --git a/compiler/ext/suggested_actions.ml b/compiler/ext/suggested_actions.ml new file mode 100644 index 0000000000..5cb19187cc --- /dev/null +++ b/compiler/ext/suggested_actions.ml @@ -0,0 +1,64 @@ +type suggested_action = + | ApplyAutomaticMigrationsForFullProject + | ApplyAutomaticMigrationsForCurrentFile + +type record = {loc: Warnings.loc; action: suggested_action} + +let suggestions : record list ref = ref [] + +let file_of_record (r : record) = r.loc.loc_start.pos_fname + +let add r = + let exists = + List.exists + (fun x -> x.action = r.action && file_of_record x = file_of_record r) + !suggestions + in + if not exists then suggestions := r :: !suggestions + +let clear () = suggestions := [] + +let has r = + List.exists + (fun x -> x.action = r.action && file_of_record x = file_of_record r) + !suggestions + +let action_id_json = function + | ApplyAutomaticMigrationsForFullProject -> + "\"ApplyAutomaticMigrationsForFullProject\"" + | ApplyAutomaticMigrationsForCurrentFile -> + "\"ApplyAutomaticMigrationsForCurrentFile\"" + +let print_block_if_any () = + match List.rev !suggestions with + | [] -> () + | xs -> + let tag_name_of_action = function + | ApplyAutomaticMigrationsForCurrentFile -> "apply_all_migrations_in_file" + | ApplyAutomaticMigrationsForFullProject -> + "apply_all_migrations_in_project" + in + let description_of_action = function + | ApplyAutomaticMigrationsForCurrentFile -> + "Applies all automatic migrations in the current file." + | ApplyAutomaticMigrationsForFullProject -> + "Applies all automatic migrations in the project." + in + let print_entry ({loc; action} : record) = + let path = + let f = loc.loc_start.pos_fname in + if Filename.is_relative f then Ext_path.absolute_cwd_path f else f + in + let tag = tag_name_of_action action in + Printf.printf " <%s>\n" tag; + Printf.printf " %s\n" + (description_of_action action); + print_endline " "; + Printf.printf " perform-action %s %s\n" path (action_id_json action); + print_endline " "; + Printf.printf " \n" tag + in + print_endline ""; + List.iter print_entry xs; + print_endline ""; + flush stdout diff --git a/compiler/ext/suggested_actions.mli b/compiler/ext/suggested_actions.mli new file mode 100644 index 0000000000..f98281939d --- /dev/null +++ b/compiler/ext/suggested_actions.mli @@ -0,0 +1,10 @@ +type suggested_action = + | ApplyAutomaticMigrationsForFullProject + | ApplyAutomaticMigrationsForCurrentFile + +type record = {loc: Warnings.loc; action: suggested_action} + +val add : record -> unit +val clear : unit -> unit +val has : record -> bool +val print_block_if_any : unit -> unit diff --git a/compiler/ext/warnings.ml b/compiler/ext/warnings.ml index f25b91b4f8..5debf3cb68 100644 --- a/compiler/ext/warnings.ml +++ b/compiler/ext/warnings.ml @@ -296,6 +296,8 @@ let reset () = let () = reset () +let llm_mode = ref false + let message = function | Comment_start -> "this is the start of a comment." | Comment_not_end -> "this is not the end of a comment." @@ -310,10 +312,15 @@ let message = function ^ if can_be_automigrated then "\n\n\ - \ This can be automatically migrated by the ReScript migration tool. \ - Run `rescript-tools migrate-all ` to run all automatic \ - migrations available in your project, or `rescript-tools migrate \ - ` to migrate a single file." + \ This can be automatically migrated by the ReScript migration tool. " + ^ + if !llm_mode then + "Run the perform-action MCP tool for automatic migration, suggested \ + below, to fix this automatically." + else + "Run `rescript-tools migrate-all ` to run all automatic \ + migrations available in your project, or `rescript-tools migrate \ + ` to migrate a single file." else "" | Fragile_match "" -> "this pattern-matching is fragile." | Fragile_match s -> diff --git a/compiler/ext/warnings.mli b/compiler/ext/warnings.mli index ba1a03ceec..b775472542 100644 --- a/compiler/ext/warnings.mli +++ b/compiler/ext/warnings.mli @@ -131,3 +131,5 @@ val loc_to_string : loc -> string (** Turn the location into a string with (line,column--line,column) format. *) + +val llm_mode : bool ref diff --git a/compiler/ml/code_frame.ml b/compiler/ml/code_frame.ml index 9f75c765ac..0d62a6f9e8 100644 --- a/compiler/ml/code_frame.ml +++ b/compiler/ml/code_frame.ml @@ -104,7 +104,7 @@ end let setup = Color.setup -type gutter = Number of int | Elided +type gutter = Number of int | Elided | UnderlinedRow type highlighted_string = {s: string; start: int; end_: int} type line = {gutter: gutter; content: highlighted_string list} @@ -116,7 +116,7 @@ type line = {gutter: gutter; content: highlighted_string list} - center snippet when it's heavily indented - ellide intermediate lines when the reported range is huge *) -let print ~is_warning ~src ~(start_pos : Lexing.position) +let print ~is_warning ~draw_underline ~src ~(start_pos : Lexing.position) ~(end_pos : Lexing.position) = let indent = 2 in let highlight_line_start_line = start_pos.pos_lnum in @@ -134,27 +134,22 @@ let print ~is_warning ~src ~(start_pos : Lexing.position) let max_line_digits_count = digits_count last_shown_line in (* TODO: change this back to a fixed 100? *) (* 3 for separator + the 2 spaces around it *) - let line_width = 78 - max_line_digits_count - indent - 3 in + let line_width = max 1 (78 - max_line_digits_count - indent - 3) in let lines = - if - start_line_line_offset >= 0 - && end_line_line_end_offset >= start_line_line_offset - then - String.sub src start_line_line_offset - (end_line_line_end_offset - start_line_line_offset) - |> String.split_on_char '\n' - |> filter_mapi (fun i line -> - let line_number = i + first_shown_line in - if more_than_5_highlighted_lines then - if line_number = highlight_line_start_line + 2 then - Some (Elided, line) - else if - line_number > highlight_line_start_line + 2 - && line_number < highlight_line_end_line - 1 - then None - else Some (Number line_number, line) - else Some (Number line_number, line)) - else [] + String.sub src start_line_line_offset + (end_line_line_end_offset - start_line_line_offset) + |> String.split_on_char '\n' + |> filter_mapi (fun i line -> + let line_number = i + first_shown_line in + if more_than_5_highlighted_lines then + if line_number = highlight_line_start_line + 2 then + Some (Elided, line) + else if + line_number > highlight_line_start_line + 2 + && line_number < highlight_line_end_line - 1 + then None + else Some (Number line_number, line) + else Some (Number line_number, line)) in let leading_space_to_cut = lines @@ -178,41 +173,98 @@ let print ~is_warning ~src ~(start_pos : Lexing.position) String.sub line leading_space_to_cut (String.length line - leading_space_to_cut) |> break_long_line line_width - |> List.mapi (fun i line -> + |> List.mapi (fun i chunk -> match gutter with - | Elided -> {s = line; start = 0; end_ = 0} + | Elided | UnderlinedRow -> + {s = chunk; start = 0; end_ = 0} | Number line_number -> - let highlight_line_start_offset = + let hl_start_off = start_pos.pos_cnum - start_pos.pos_bol in - let highlight_line_end_offset = - end_pos.pos_cnum - end_pos.pos_bol + let hl_end_off = end_pos.pos_cnum - end_pos.pos_bol in + (* Offsets within the trimmed line (after leading-space cut) *) + let trimmed_hl_start = + if line_number = highlight_line_start_line then + max 0 (hl_start_off - leading_space_to_cut) + else if line_number > highlight_line_start_line then 0 + else (* before start line *) max_int in - let start = - if i = 0 && line_number = highlight_line_start_line - then - highlight_line_start_offset - leading_space_to_cut + let trimmed_hl_end = + if line_number < highlight_line_start_line then 0 + else if line_number = highlight_line_start_line + && line_number = highlight_line_end_line + then max 0 (hl_end_off - leading_space_to_cut) + else if line_number = highlight_line_start_line then + (* highlight runs through end of this line *) + String.length chunk (* placeholder; refined below *) + else if line_number < highlight_line_end_line then + (* full line highlight for interior lines *) + String.length chunk (* placeholder; refined below *) + else if line_number = highlight_line_end_line then + max 0 (hl_end_off - leading_space_to_cut) else 0 in - let end_ = - if line_number < highlight_line_start_line then 0 - else if + (* Map highlight to this chunk using its offset *) + let chunk_offset = i * line_width in + let clen = String.length chunk in + let start_rel = + if trimmed_hl_start = max_int then 0 + else trimmed_hl_start - chunk_offset + in + let end_rel = + if line_number = highlight_line_start_line - && line_number = highlight_line_end_line - then highlight_line_end_offset - leading_space_to_cut - else if line_number = highlight_line_start_line then - String.length line + && line_number <> highlight_line_end_line + && i = 0 + then clen else if line_number > highlight_line_start_line && line_number < highlight_line_end_line - then String.length line - else if line_number = highlight_line_end_line then - highlight_line_end_offset - leading_space_to_cut - else 0 + then clen + else trimmed_hl_end - chunk_offset in - {s = line; start; end_}) + let start = max 0 (min clen start_rel) in + let end_ = max 0 (min clen end_rel) in + let start, end_ = + if start >= end_ then (0, 0) else (start, end_) + in + {s = chunk; start; end_}) in - {gutter; content = new_content}) + if draw_underline then + let has_highlight = + List.exists (fun {start; end_} -> start < end_) new_content + in + if has_highlight then + let underline_content = + List.map + (fun {start; end_} -> + if start < end_ then + let overline_char = "^" in + let underline_length = end_ - start in + let underline = + String.concat "" + (List.init underline_length (fun _ -> overline_char)) + in + [ + { + s = String.make start ' ' ^ underline; + start = 0; + end_ = 0; + }; + ] + else [{s = ""; start = 0; end_ = 0}]) + new_content + in + [ + {gutter; content = new_content}; + { + gutter = UnderlinedRow; + content = List.flatten underline_content; + }; + ] + else [{gutter; content = new_content}] + else [{gutter; content = new_content}]) + |> List.flatten in let buf = Buffer.create 100 in let open Color in @@ -280,5 +332,11 @@ let print ~is_warning ~src ~(start_pos : Lexing.position) else NoColor in add_ch c ch); + add_ch NoColor '\n') + | UnderlinedRow -> + content + |> List.iter (fun line -> + draw_gutter NoColor ""; + line.s |> String.iter (fun ch -> add_ch NoColor ch); add_ch NoColor '\n')); Buffer.contents buf diff --git a/compiler/ml/location.ml b/compiler/ml/location.ml index fa2e806db0..141bac0505 100644 --- a/compiler/ml/location.ml +++ b/compiler/ml/location.ml @@ -39,6 +39,8 @@ let show_filename file = if file = "_none_" then !input_name else file let print_filename ppf file = Format.fprintf ppf "%s" (show_filename file) +let draw_underline_in_code_frame = ref false + (* return file, line, char from the given position *) let get_pos_info pos = (pos.pos_fname, pos.pos_lnum, pos.pos_cnum - pos.pos_bol) @@ -140,8 +142,10 @@ let print ?(src = None) ~message_kind intro ppf (loc : t) = branch might not be reached (aka no inline file content display) so we don't wanna end up with two line breaks in the the consequent *) fprintf ppf "@,%s" - (Code_frame.print ~is_warning:(message_kind = `warning) ~src - ~start_pos:loc.loc_start ~end_pos:loc.loc_end) + (Code_frame.print + ~draw_underline:!draw_underline_in_code_frame + ~is_warning:(message_kind = `warning) ~src ~start_pos:loc.loc_start + ~end_pos:loc.loc_end) with (* this might happen if the file is e.g. "", "_none_" or any of the fake file name placeholders. we've already printed the location above, so nothing more to do here. *) @@ -300,6 +304,14 @@ let raise_errorf ?(loc = none) ?(sub = []) ?(if_highlight = "") = let deprecated ?(can_be_automigrated = false) ?(def = none) ?(use = none) loc msg = + if + can_be_automigrated && !Warnings.llm_mode && (not loc.loc_ghost) + && loc.loc_start.pos_fname <> "_none_" + then ( + Suggested_actions.add + {loc; action = Suggested_actions.ApplyAutomaticMigrationsForCurrentFile}; + Suggested_actions.add + {loc; action = Suggested_actions.ApplyAutomaticMigrationsForFullProject}); prerr_warning loc (Warnings.Deprecated (msg, def, use, can_be_automigrated)) let map_loc f {txt; loc} = {txt = f txt; loc} diff --git a/compiler/ml/location.mli b/compiler/ml/location.mli index 76f4db2bd8..400be9a36f 100644 --- a/compiler/ml/location.mli +++ b/compiler/ml/location.mli @@ -42,6 +42,8 @@ val set_input_name : string -> unit val get_pos_info : Lexing.position -> string * int * int (* file, line, char *) val print_loc : formatter -> t -> unit +val draw_underline_in_code_frame : bool ref + val prerr_warning : t -> Warnings.t -> unit val warning_printer : (t -> formatter -> Warnings.t -> unit) ref diff --git a/rewatch/Cargo.lock b/rewatch/Cargo.lock index f79d2871b9..f61c2b3392 100644 --- a/rewatch/Cargo.lock +++ b/rewatch/Cargo.lock @@ -24,6 +24,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "0.6.19" @@ -92,6 +101,29 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "bitflags" version = "1.3.2" @@ -123,6 +155,12 @@ version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +[[package]] +name = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" + [[package]] name = "cc" version = "1.2.28" @@ -144,6 +182,20 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chrono" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "clap" version = "4.5.40" @@ -278,6 +330,32 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "either" version = "1.15.0" @@ -331,6 +409,15 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "fsevent-sys" version = "4.1.0" @@ -465,6 +552,132 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b112acc8b3adf4b107a8ec20977da0273a8c386765a3ec0229bd500a1443f9f" +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indicatif" version = "0.17.11" @@ -551,6 +764,12 @@ dependencies = [ "libc", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.174" @@ -574,12 +793,94 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "mcp-macros" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a98bb059ac88c0823eca2b83d0971b15c6ea361dba44dcbdd3d301103baab0b6" +dependencies = [ + "async-trait", + "convert_case", + "mcp-spec", + "proc-macro2", + "quote", + "schemars", + "serde", + "serde_json", + "syn", +] + +[[package]] +name = "mcp-server" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d1d33bbd1f184d136aedf02a21e1342b2a527400a513c07d4fd30846e978039" +dependencies = [ + "anyhow", + "async-trait", + "futures", + "mcp-macros", + "mcp-spec", + "pin-project", + "schemars", + "serde", + "serde_json", + "thiserror", + "tokio", + "tower", + "tower-service", + "tracing", + "tracing-appender", + "tracing-subscriber", +] + +[[package]] +name = "mcp-spec" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a294f4fcf9c3a0b3d168937947ecb906feb691089dd9fbbefc9707c8a4557163" +dependencies = [ + "anyhow", + "async-trait", + "base64", + "chrono", + "schemars", + "serde", + "serde_json", + "thiserror", + "url", +] + [[package]] name = "memchr" version = "2.7.5" @@ -598,6 +899,17 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "mio" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" +dependencies = [ + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.61.2", +] + [[package]] name = "nix" version = "0.30.1" @@ -623,7 +935,7 @@ dependencies = [ "inotify", "kqueue", "libc", - "mio", + "mio 0.8.11", "serde", "walkdir", "windows-sys 0.45.0", @@ -638,6 +950,30 @@ dependencies = [ "winapi", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "num_cpus" version = "1.17.0" @@ -666,6 +1002,55 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -684,6 +1069,21 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "proc-macro2" version = "1.0.95" @@ -783,6 +1183,8 @@ dependencies = [ "futures-timer", "indicatif", "log", + "mcp-server", + "mcp-spec", "notify", "num_cpus", "rayon", @@ -791,6 +1193,7 @@ dependencies = [ "serde_json", "sysinfo", "tempfile", + "tokio", ] [[package]] @@ -806,6 +1209,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + [[package]] name = "ryu" version = "1.0.20" @@ -821,6 +1230,36 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "serde" version = "1.0.219" @@ -841,6 +1280,17 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "serde_json" version = "1.0.140" @@ -853,6 +1303,15 @@ dependencies = [ "serde", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "1.3.0" @@ -860,11 +1319,42 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] -name = "slab" +name = "signal-hook-registry" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" +dependencies = [ + "libc", +] + +[[package]] +name = "slab" version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "strsim" version = "0.11.1" @@ -882,6 +1372,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "sysinfo" version = "0.29.11" @@ -919,6 +1420,203 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" + +[[package]] +name = "time-macros" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +dependencies = [ + "bytes", + "libc", + "mio 1.1.0", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "pin-project-lite", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-appender" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3566e8ce28cc0a3fe42519fc80e6b4c943cc4c8cef275620eb8dac2d3d4e06cf" +dependencies = [ + "crossbeam-channel", + "thiserror", + "time", + "tracing-subscriber", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + [[package]] name = "unicode-ident" version = "1.0.18" @@ -937,12 +1635,36 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c" +[[package]] +name = "url" +version = "2.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "version_check" version = "0.9.5" @@ -982,6 +1704,7 @@ checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" dependencies = [ "cfg-if", "once_cell", + "rustversion", "wasm-bindgen-macro", ] @@ -1072,6 +1795,65 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.45.0" @@ -1099,6 +1881,24 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-targets" version = "0.42.2" @@ -1138,13 +1938,30 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", + "windows_i686_gnullvm 0.52.6", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.42.2" @@ -1163,6 +1980,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.42.2" @@ -1181,6 +2004,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.42.2" @@ -1199,12 +2028,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.42.2" @@ -1223,6 +2064,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.42.2" @@ -1241,6 +2088,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" @@ -1259,6 +2112,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.42.2" @@ -1277,6 +2136,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "wit-bindgen-rt" version = "0.39.0" @@ -1286,6 +2151,35 @@ dependencies = [ "bitflags 2.9.1", ] +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "zerocopy" version = "0.8.26" @@ -1305,3 +2199,57 @@ dependencies = [ "quote", "syn", ] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/rewatch/Cargo.toml b/rewatch/Cargo.toml index df5f6cd6bc..11e99208b2 100644 --- a/rewatch/Cargo.toml +++ b/rewatch/Cargo.toml @@ -26,6 +26,9 @@ serde = { version = "1.0.152", features = ["derive"] } serde_json = { version = "1.0.93" } sysinfo = "0.29.10" tempfile = "3.10.1" +mcp-server = "0.1.0" +mcp-spec = "0.1.0" +tokio = { version = "1", features = ["rt-multi-thread", "io-std"] } [profile.release] diff --git a/rewatch/src/build/compile.rs b/rewatch/src/build/compile.rs index 8048764f09..aaaa8d1825 100644 --- a/rewatch/src/build/compile.rs +++ b/rewatch/src/build/compile.rs @@ -436,39 +436,17 @@ pub fn compiler_args( // Command-line --warn-error flag override (takes precedence over rescript.json config) warn_error_override: Option, ) -> Result> { - let bsc_flags = config::flatten_flags(&config.compiler_flags); - let dependency_paths = get_dependency_paths(config, project_context, packages, is_type_dev); let module_name = helpers::file_path_to_module_name(file_path, &config.get_namespace()); - - let namespace_args = match &config.get_namespace() { - packages::Namespace::NamespaceWithEntry { namespace: _, entry } if &module_name == entry => { - // if the module is the entry we just want to open the namespace - vec![ - "-open".to_string(), - config.get_namespace().to_suffix().unwrap().to_string(), - ] - } - packages::Namespace::Namespace(_) - | packages::Namespace::NamespaceWithEntry { - namespace: _, - entry: _, - } => { - vec![ - "-bs-ns".to_string(), - config.get_namespace().to_suffix().unwrap().to_string(), - ] - } - packages::Namespace::NoNamespace => vec![], - }; - - let root_config = project_context.get_root_config(); - let jsx_args = root_config.get_jsx_args(); - let jsx_module_args = root_config.get_jsx_module_args(); - let jsx_mode_args = root_config.get_jsx_mode_args(); - let jsx_preserve_args = root_config.get_jsx_preserve_args(); - let gentype_arg = config.get_gentype_arg(); - let experimental_args = root_config.get_experimental_features_args(); - let warning_args = config.get_warning_args(is_local_dep, warn_error_override); + let base_args = base_compile_args( + config, + file_path, + project_context, + packages, + is_type_dev, + is_local_dep, + warn_error_override, + None, + )?; let read_cmi_args = match has_interface { true => { @@ -483,6 +461,7 @@ pub fn compiler_args( let package_name_arg = vec!["-bs-package-name".to_string(), config.name.to_owned()]; + let root_config = project_context.get_root_config(); let implementation_args = if is_interface { debug!("Compiling interface file: {}", &module_name); vec![] @@ -517,11 +496,117 @@ pub fn compiler_args( .collect() }; + Ok([ + base_args, + read_cmi_args, + // vec!["-warn-error".to_string(), "A".to_string()], + // ^^ this one fails for bisect-ppx + // this is the default + // we should probably parse the right ones from the package config + // vec!["-w".to_string(), "a".to_string()], + package_name_arg, + implementation_args, + vec![ast_path.to_string_lossy().to_string()], + ] + .concat()) +} + +pub fn compiler_args_for_diagnostics( + config: &config::Config, + file_path: &Path, + is_interface: bool, + has_interface: bool, + project_context: &ProjectContext, + packages: &Option<&AHashMap>, + is_type_dev: bool, + is_local_dep: bool, + warn_error_override: Option, + ppx_flags: Vec, +) -> Result> { + let mut args = base_compile_args( + config, + file_path, + project_context, + packages, + is_type_dev, + is_local_dep, + warn_error_override, + Some(ppx_flags), + )?; + + // Gate -bs-read-cmi by .cmi presence to avoid noisy errors when a project hasn't been built yet. + if has_interface && !is_interface { + let pkg_root = config + .path + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| Path::new(".").to_path_buf()); + let ocaml_build_path = packages::get_ocaml_build_path(&pkg_root); + let basename = helpers::file_path_to_compiler_asset_basename(file_path, &config.get_namespace()); + let cmi_exists = ocaml_build_path.join(format!("{basename}.cmi")).exists(); + if cmi_exists { + args.push("-bs-read-cmi".to_string()); + } + } + + args.extend([ + "-color".to_string(), + "never".to_string(), + "-ignore-parse-errors".to_string(), + "-editor-mode".to_string(), + "-llm-mode".to_string(), + ]); + + args.push(file_path.to_string_lossy().to_string()); + Ok(args) +} + +fn base_compile_args( + config: &config::Config, + file_path: &Path, + project_context: &ProjectContext, + packages: &Option<&AHashMap>, + is_type_dev: bool, + is_local_dep: bool, + warn_error_override: Option, + include_ppx_flags: Option>, +) -> Result> { + let bsc_flags = config::flatten_flags(&config.compiler_flags); + let dependency_paths = get_dependency_paths(config, project_context, packages, is_type_dev); + let module_name = helpers::file_path_to_module_name(file_path, &config.get_namespace()); + + let namespace_args = match &config.get_namespace() { + packages::Namespace::NamespaceWithEntry { namespace: _, entry } if &module_name == entry => { + vec![ + "-open".to_string(), + config.get_namespace().to_suffix().unwrap().to_string(), + ] + } + packages::Namespace::Namespace(_) + | packages::Namespace::NamespaceWithEntry { + namespace: _, + entry: _, + } => { + vec![ + "-bs-ns".to_string(), + config.get_namespace().to_suffix().unwrap().to_string(), + ] + } + packages::Namespace::NoNamespace => vec![], + }; + + let root_config = project_context.get_root_config(); + let jsx_args = root_config.get_jsx_args(); + let jsx_module_args = root_config.get_jsx_module_args(); + let jsx_mode_args = root_config.get_jsx_mode_args(); + let jsx_preserve_args = root_config.get_jsx_preserve_args(); + let gentype_arg = config.get_gentype_arg(); + let experimental_args = root_config.get_experimental_features_args(); + let warning_args = config.get_warning_args(is_local_dep, warn_error_override); let runtime_path_args = get_runtime_path_args(config, project_context)?; Ok(vec![ namespace_args, - read_cmi_args, vec![ "-I".to_string(), Path::new("..").join("ocaml").to_string_lossy().to_string(), @@ -532,22 +617,11 @@ pub fn compiler_args( jsx_module_args, jsx_mode_args, jsx_preserve_args, + include_ppx_flags.unwrap_or_default(), bsc_flags.to_owned(), warning_args, gentype_arg, experimental_args, - // vec!["-warn-error".to_string(), "A".to_string()], - // ^^ this one fails for bisect-ppx - // this is the default - // we should probably parse the right ones from the package config - // vec!["-w".to_string(), "a".to_string()], - package_name_arg, - implementation_args, - // vec![ - // "-I".to_string(), - // abs_node_modules_path.to_string() + "/rescript/ocaml", - // ], - vec![ast_path.to_string_lossy().to_string()], ] .concat()) } diff --git a/rewatch/src/build/parse.rs b/rewatch/src/build/parse.rs index 22ab88ff46..6186055be4 100644 --- a/rewatch/src/build/parse.rs +++ b/rewatch/src/build/parse.rs @@ -284,11 +284,7 @@ pub fn parser_args( let root_config = project_context.get_root_config(); let file = &filename; let ast_path = helpers::get_ast_path(file); - let ppx_flags = config::flatten_ppx_flags( - project_context, - package_config, - &filter_ppx_flags(&package_config.ppx_flags, contents), - )?; + let ppx_flags = ppx_flags_for_contents(project_context, package_config, contents)?; let jsx_args = root_config.get_jsx_args(); let jsx_module_args = root_config.get_jsx_module_args(); let jsx_mode_args = root_config.get_jsx_mode_args(); @@ -322,6 +318,15 @@ pub fn parser_args( )) } +pub fn ppx_flags_for_contents( + project_context: &ProjectContext, + package_config: &Config, + contents: &str, +) -> anyhow::Result> { + let filtered = filter_ppx_flags(&package_config.ppx_flags, contents); + config::flatten_ppx_flags(project_context, package_config, &filtered) +} + fn generate_ast( package: Package, filename: &Path, diff --git a/rewatch/src/cli.rs b/rewatch/src/cli.rs index 3b4604ce54..fa77347a14 100644 --- a/rewatch/src/cli.rs +++ b/rewatch/src/cli.rs @@ -425,6 +425,8 @@ pub enum Command { Build(BuildArgs), /// Build, then start a watcher Watch(WatchArgs), + /// Start a Model Context Protocol (MCP) server over stdio + Mcp {}, /// Clean the build artifacts Clean { #[command(flatten)] diff --git a/rewatch/src/lib.rs b/rewatch/src/lib.rs index a389e8172e..b4b9dc6d16 100644 --- a/rewatch/src/lib.rs +++ b/rewatch/src/lib.rs @@ -5,6 +5,7 @@ pub mod config; pub mod format; pub mod helpers; pub mod lock; +pub mod mcp; pub mod project_context; pub mod queue; pub mod sourcedirs; diff --git a/rewatch/src/main.rs b/rewatch/src/main.rs index ba74dbd393..c72f4cf08f 100644 --- a/rewatch/src/main.rs +++ b/rewatch/src/main.rs @@ -3,17 +3,23 @@ use console::Term; use log::LevelFilter; use std::{io::Write, path::Path}; -use rescript::{build, cli, cmd, format, lock, watcher}; +use rescript::{build, cli, cmd, format, lock, mcp, watcher}; fn main() -> Result<()> { let cli = cli::parse_with_default().unwrap_or_else(|err| err.exit()); let log_level_filter = cli.verbose.log_level_filter(); + // Route logs to stderr when running the MCP server to keep stdout as a pure JSON-RPC stream. + let logs_to_stdout = !matches!(cli.command, cli::Command::Mcp { .. }); env_logger::Builder::new() .format(|buf, record| writeln!(buf, "{}:\n{}", record.level(), record.args())) .filter_level(log_level_filter) - .target(env_logger::fmt::Target::Stdout) + .target(if logs_to_stdout { + env_logger::fmt::Target::Stdout + } else { + env_logger::fmt::Target::Stderr + }) .init(); let mut command = cli.command; @@ -37,6 +43,13 @@ fn main() -> Result<()> { println!("{}", build::get_compiler_args(Path::new(&path))?); std::process::exit(0); } + cli::Command::Mcp {} => { + if let Err(e) = mcp::run() { + println!("{e}"); + std::process::exit(1); + } + std::process::exit(0); + } cli::Command::Build(build_args) => { let _lock = get_lock(&build_args.folder); diff --git a/rewatch/src/mcp.rs b/rewatch/src/mcp.rs new file mode 100644 index 0000000000..4ab396470c --- /dev/null +++ b/rewatch/src/mcp.rs @@ -0,0 +1,385 @@ +use anyhow::Result; +use mcp_server::router::{CapabilitiesBuilder, Router, RouterService}; +use mcp_server::{ByteTransport, Server}; +use mcp_spec::{ + content::Content, prompt::Prompt, protocol::ServerCapabilities, resource::Resource, tool::Tool, +}; +use serde_json::json; +use std::path::Path; +use std::pin::Pin; +use std::process::Command; + +use crate::{build, helpers, project_context::ProjectContext}; +use serde::Deserialize; + +// Suggested actions mirror compiler/ext/suggested_actions.ml +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "PascalCase")] +enum SuggestedAction { + ApplyAutomaticMigrationsForFullProject, + ApplyAutomaticMigrationsForCurrentFile, +} + +#[derive(Clone)] +pub struct RewatchMcp; + +impl RewatchMcp {} + +impl Router for RewatchMcp { + fn name(&self) -> String { + "rescript-mcp".to_string() + } + + fn instructions(&self) -> String { + "ReScript MCP server.\n\n\ + Tools:\n\ + - diagnose(path): Quick per-file diagnostics after edits. Prefer this before a full build.\n\ + No writes. Only this file (not dependents). Returns 'OK' if clean.\n\ + - perform-action(path, actionId): Applies an action suggested by the compiler/diagnosis." + .to_string() + } + + fn capabilities(&self) -> ServerCapabilities { + CapabilitiesBuilder::new().with_tools(false).build() + } + + fn list_tools(&self) -> Vec { + vec![ + Tool::new( + "diagnose", + "Quick per-file diagnostics (shows compiler warnings, errors) after edits; prefer before full build; no writes; only this file.", + json!({ + "type": "object", + "properties": {"path": {"type": "string"}}, + "required": ["path"], + "additionalProperties": false + }), + ), + Tool::new( + "perform-action", + "Applies an action suggested by the compiler/diagnosis.", + json!({ + "type": "object", + "properties": { + "path": {"type": "string"}, + // Accept either a string or an object so clients can pass raw JSON, not stringified JSON + "actionId": {"anyOf": [{"type": "string"}, {"type": "object"}]} + }, + "required": ["path", "actionId"], + "additionalProperties": false + }), + ), + ] + } + + fn call_tool( + &self, + tool_name: &str, + arguments: serde_json::Value, + ) -> Pin< + Box< + dyn futures::Future, mcp_spec::handler::ToolError>> + Send + 'static, + >, + > { + let name = tool_name.to_string(); + Box::pin(async move { + match name.as_str() { + "diagnose" => { + let args = arguments; + match tokio::task::spawn_blocking(move || diagnose_impl(args)).await { + Ok(Ok((msg, is_error))) => { + let text = msg.unwrap_or_else(|| "OK".into()); + if is_error { + Err(mcp_spec::handler::ToolError::ExecutionError(text)) + } else { + Ok(vec![Content::text(text)]) + } + } + Ok(Err(e)) => Err(mcp_spec::handler::ToolError::ExecutionError(e.to_string())), + Err(join_err) => Err(mcp_spec::handler::ToolError::ExecutionError(format!( + "Join error: {join_err}" + ))), + } + } + "perform-action" => { + let args = arguments; + match tokio::task::spawn_blocking(move || perform_action_impl(args)).await { + Ok(Ok(())) => Ok(vec![Content::text("OK")]), + Ok(Err(e)) => Err(mcp_spec::handler::ToolError::ExecutionError(e.to_string())), + Err(join_err) => Err(mcp_spec::handler::ToolError::ExecutionError(format!( + "Join error: {join_err}" + ))), + } + } + other => Err(mcp_spec::handler::ToolError::NotFound(format!( + "Unknown tool: {other}" + ))), + } + }) + } + + fn list_resources(&self) -> Vec { + Vec::new() + } + + fn read_resource( + &self, + _uri: &str, + ) -> Pin< + Box> + Send + 'static>, + > { + Box::pin(async move { Ok(String::new()) }) + } + + fn list_prompts(&self) -> Vec { + Vec::new() + } + + fn get_prompt( + &self, + _prompt_name: &str, + ) -> Pin> + Send + 'static>> + { + Box::pin(async move { Err(mcp_spec::handler::PromptError::NotFound("prompt".into())) }) + } +} + +pub fn run() -> Result<()> { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(async move { + let router = RewatchMcp; + let service = RouterService(router); + let server = Server::new(service); + let transport = ByteTransport::new(tokio::io::stdin(), tokio::io::stdout()); + server.run(transport).await.map_err(|e| anyhow::anyhow!(e)) + }) +} + +fn diagnose_impl(arguments: serde_json::Value) -> anyhow::Result<(Option, bool)> { + use anyhow::anyhow; + + let path = arguments + .get("path") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("Missing required string argument 'path'"))?; + + let file_abs = helpers::get_abs_path(Path::new(path)); + match file_abs.extension().and_then(|e| e.to_str()) { + Some("res") | Some("resi") => {} + _ => { + return Err(anyhow!( + "Unsupported file extension. Expected a .res or .resi file: {}", + file_abs.display() + )); + } + } + if !file_abs.exists() { + return Err(anyhow!(format!("File not found: {}", file_abs.display()))); + } + + let package_root = helpers::get_nearest_config(&file_abs).ok_or_else(|| { + anyhow!(format!( + "Could not find rescript.json/bsconfig.json for {}", + file_abs.display() + )) + })?; + + let lib_bs_dir = build::packages::get_build_path(&package_root); + + let project = ProjectContext::new(&package_root)?; + let rel = file_abs + .strip_prefix(&package_root) + .map_err(|_| anyhow!("File is not inside project root"))?; + let contents = helpers::read_file(&file_abs).map_err(|e| anyhow!(format!("Failed to read file: {e}")))?; + + let ppx_flags = build::parse::ppx_flags_for_contents(&project, &project.current_config, &contents)?; + let is_interface = file_abs.extension().map(|e| e == "resi").unwrap_or(false); + let has_interface = if is_interface { + true + } else { + file_abs.with_extension("resi").exists() + }; + let is_type_dev = project.current_config.find_is_type_dev_for_path(rel); + + let compiler_args_vec = build::compile::compiler_args_for_diagnostics( + &project.current_config, + &file_abs, + is_interface, + has_interface, + &project, + &None, + is_type_dev, + true, + None, + ppx_flags, + )?; + + let bsc = build::get_compiler_info(&project)?.bsc_path; + let output = Command::new(&bsc) + .current_dir(&lib_bs_dir) + .args(&compiler_args_vec) + .output() + .map_err(|e| anyhow!(format!("Failed to run bsc (incremental): {e}")))?; + + let mut diag = String::new(); + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + if helpers::contains_ascii_characters(&stderr) { + diag.push_str(&stderr); + } + if helpers::contains_ascii_characters(&stdout) { + if !diag.is_empty() && !diag.ends_with('\n') { + diag.push('\n'); + } + diag.push_str(&stdout); + } + + let is_error = !output.status.success(); + let message_opt = if diag.trim().is_empty() { None } else { Some(diag) }; + Ok((message_opt, is_error)) +} + +fn perform_action_impl(arguments: serde_json::Value) -> anyhow::Result<()> { + use anyhow::anyhow; + + // Validate required arguments + let path_arg = arguments + .get("path") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("Missing required string argument 'path'"))?; + + let action_id_str = arguments + .get("actionId") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("Missing required string argument 'actionId'"))?; + + // actionId might be: + // - A plain string like: "ApplyAutomaticMigrationsForCurrentFile" + // - Stringified JSON of a string: "\"ApplyAutomaticMigrationsForCurrentFile\"" + // - Stringified JSON of an object with an action string (future-proof) + // Accept them in this order: plain string → JSON enum string → JSON object + let action: SuggestedAction = match action_id_str { + // Plain string case + "ApplyAutomaticMigrationsForCurrentFile" => SuggestedAction::ApplyAutomaticMigrationsForCurrentFile, + "ApplyAutomaticMigrationsForFullProject" => SuggestedAction::ApplyAutomaticMigrationsForFullProject, + // Otherwise try JSON parsing paths + _ => { + // Try to parse as a JSON string directly into enum (e.g. "\"Apply…\"") + match serde_json::from_str::(action_id_str) { + Ok(a) => a, + Err(_) => { + // Try a generic JSON value to support string or object shapes + let v: serde_json::Value = serde_json::from_str(action_id_str) + .map_err(|e| anyhow!(format!("Invalid actionId JSON or unsupported action: {e}")))?; + match v { + serde_json::Value::String(s) => match s.as_str() { + "ApplyAutomaticMigrationsForCurrentFile" => { + SuggestedAction::ApplyAutomaticMigrationsForCurrentFile + } + "ApplyAutomaticMigrationsForFullProject" => { + SuggestedAction::ApplyAutomaticMigrationsForFullProject + } + other => return Err(anyhow!(format!("Unknown action kind: {other}"))), + }, + serde_json::Value::Object(map) => { + let s = map + .get("action") + .and_then(|v| v.as_str()) + .or_else(|| map.get("kind").and_then(|v| v.as_str())) + .or_else(|| map.get("type").and_then(|v| v.as_str())) + .ok_or_else(|| { + anyhow!("actionId JSON did not contain a recognizable action string") + })?; + match s { + "ApplyAutomaticMigrationsForCurrentFile" => { + SuggestedAction::ApplyAutomaticMigrationsForCurrentFile + } + "ApplyAutomaticMigrationsForFullProject" => { + SuggestedAction::ApplyAutomaticMigrationsForFullProject + } + other => return Err(anyhow!(format!("Unknown action kind: {other}"))), + } + } + _ => { + return Err(anyhow!( + "actionId JSON did not contain a recognizable action string" + )); + } + } + } + } + } + }; + + let abs_path = helpers::get_abs_path(Path::new(path_arg)); + + // Find project root (nearest config) for both actions + let package_root = helpers::get_nearest_config(&abs_path).ok_or_else(|| { + anyhow!(format!( + "Could not find rescript.json/bsconfig.json for {}", + abs_path.display() + )) + })?; + + let project = ProjectContext::new(&package_root)?; + let bin_dir = build::get_compiler_info(&project)? + .bsc_path + .parent() + .ok_or_else(|| anyhow!("Could not determine bin directory from bsc path"))? + .to_path_buf(); + let rescript_tools = bin_dir.join("rescript-tools.exe"); + if !rescript_tools.exists() { + return Err(anyhow!(format!( + "Could not find rescript-tools at {}", + rescript_tools.display() + ))); + } + + let run = |args: Vec<&str>, cwd: &Path| -> anyhow::Result<()> { + let output = Command::new(&rescript_tools) + .current_dir(cwd) + .args(args) + .output() + .map_err(|e| anyhow!(format!("Failed to run rescript-tools: {e}")))?; + if output.status.success() { + Ok(()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + let mut msg = String::new(); + if helpers::contains_ascii_characters(&stderr) { + msg.push_str(&stderr); + } + if helpers::contains_ascii_characters(&stdout) { + if !msg.is_empty() && !msg.ends_with('\n') { + msg.push('\n'); + } + msg.push_str(&stdout); + } + if msg.trim().is_empty() { + Err(anyhow!("rescript-tools failed")) + } else { + Err(anyhow!(msg.trim().to_string())) + } + } + }; + + match action { + SuggestedAction::ApplyAutomaticMigrationsForCurrentFile => { + if abs_path.is_dir() { + return Err(anyhow!("Expected a file path for single-file migration")); + } + run( + vec!["migrate", abs_path.to_string_lossy().as_ref()], + &package_root, + ) + } + SuggestedAction::ApplyAutomaticMigrationsForFullProject => { + // Use the project root for migrate-all + run( + vec!["migrate-all", package_root.to_string_lossy().as_ref()], + &package_root, + ) + } + } +} diff --git a/rewatch/tests/mcp.sh b/rewatch/tests/mcp.sh new file mode 100755 index 0000000000..8a3d553823 --- /dev/null +++ b/rewatch/tests/mcp.sh @@ -0,0 +1,86 @@ +#!/bin/bash +set -e +cd $(dirname $0) +source ./utils.sh + +bold "Test: MCP tools and diagnose" + +# Ensure env like suite.sh (RESCRIPT_BSC_EXE/RESCRIPT_RUNTIME) +if [[ -z "$RESCRIPT_BSC_EXE" || -z "$RESCRIPT_RUNTIME" ]]; then + eval $(node ./get_bin_paths.js) + export RESCRIPT_BSC_EXE + export RESCRIPT_RUNTIME +fi + +# 1) tools/list contains diagnose +resp=$(printf '{"jsonrpc":"2.0","id":1,"method":"tools/list"}\n' | "$REWATCH_EXECUTABLE" mcp 2>/dev/null) +echo "$resp" | grep -q '"name":"diagnose"' || { error "tools/list missing diagnose"; echo "$resp"; exit 1; } + +success "tools/list contains expected tools" + +# 2) diagnose valid file (should not be error) +resp=$(printf '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"diagnose","arguments":{"path":"../testrepo/src/Test.res"}}}\n' | "$REWATCH_EXECUTABLE" mcp 2>/dev/null) +echo "$resp" | grep -q '"isError":true' && { error "diagnose on valid file returned error"; echo "$resp"; exit 1; } +echo "$resp" | grep -q '"text":"OK"' || { error "diagnose on valid file did not return OK"; echo "$resp"; exit 1; } + +success "diagnose on valid file OK" + +# 3) diagnose invalid file (should be error) +resp=$(printf '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"diagnose","arguments":{"path":"../testrepo/src/DoesNotExist.res"}}}\n' | "$REWATCH_EXECUTABLE" mcp 2>/dev/null) +echo "$resp" | grep -q '"isError":true' || { error "diagnose on invalid file did not error"; echo "$resp"; exit 1; } + +success "diagnose on invalid file errors as expected" + +# 4) diagnose syntax error (should include diagnostic content) +tmpfile="../testrepo/src/Bad_mcp.res" +echo "let x = " > "$tmpfile" +resp=$(printf '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"diagnose","arguments":{"path":"testrepo/src/Bad_mcp.res"}}}\n' | "$REWATCH_EXECUTABLE" mcp 2>/dev/null) +rm -f "$tmpfile" + +echo "$resp" | grep -q '"content":\[' || { error "diagnose did not return content for syntax error"; echo "$resp"; exit 1; } + +success "diagnose reports syntax errors with content" + +bold "Test: MCP perform-action (single file)" + +# ApplyAutomaticMigrationsForCurrentFile on a simple file (should no-op and return OK) +resp=$(cat <<'JSON' | "$REWATCH_EXECUTABLE" mcp 2>/dev/null +{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"perform-action","arguments":{"path":"../testrepo/src/Test.res","actionId":"\"ApplyAutomaticMigrationsForCurrentFile\""}}} +JSON +) +echo "$resp" | grep -q '"isError":true' && { error "perform-action (single file) returned error"; echo "$resp"; exit 1; } +echo "$resp" | grep -q '"text":"OK"' || { error "perform-action (single file) did not return OK"; echo "$resp"; exit 1; } + +success "perform-action (single file) OK" + +bold "Test: MCP perform-action (full project)" + +# ApplyAutomaticMigrationsForFullProject from a file path (server resolves project root); expect OK +resp=$(cat <<'JSON' | "$REWATCH_EXECUTABLE" mcp 2>/dev/null +{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"perform-action","arguments":{"path":"../testrepo/src/Test.res","actionId":"\"ApplyAutomaticMigrationsForFullProject\""}}} +JSON +) +echo "$resp" | grep -q '"isError":true' && { error "perform-action (full project) returned error"; echo "$resp"; exit 1; } +echo "$resp" | grep -q '"text":"OK"' || { error "perform-action (full project) did not return OK"; echo "$resp"; exit 1; } + +success "perform-action (full project) OK" + +bold "Test: MCP perform-action accepts plain string actionId" + +# Single file action with plain string (not stringified JSON) +resp=$(cat <<'JSON' | "$REWATCH_EXECUTABLE" mcp 2>/dev/null +{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"perform-action","arguments":{"path":"../testrepo/src/Test.res","actionId":"ApplyAutomaticMigrationsForCurrentFile"}}} +JSON +) +echo "$resp" | grep -q '"isError":true' && { error "perform-action (plain string, single file) returned error"; echo "$resp"; exit 1; } +echo "$resp" | grep -q '"text":"OK"' || { error "perform-action (plain string, single file) did not return OK"; echo "$resp"; exit 1; } + +# Project-wide action with plain string +resp=$(cat <<'JSON' | "$REWATCH_EXECUTABLE" mcp 2>/dev/null +{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"perform-action","arguments":{"path":"../testrepo/src/Test.res","actionId":"ApplyAutomaticMigrationsForFullProject"}}} +JSON +) +echo "$resp" | grep -q '"isError":true' && { error "perform-action (plain string, full project) returned error"; echo "$resp"; exit 1; } +echo "$resp" | grep -q '"text":"OK"' || { error "perform-action (plain string, full project) did not return OK"; echo "$resp"; exit 1; } + +success "perform-action accepts plain string actionId" diff --git a/rewatch/tests/suite.sh b/rewatch/tests/suite.sh index 08af67d5c3..fc7c057516 100755 --- a/rewatch/tests/suite.sh +++ b/rewatch/tests/suite.sh @@ -53,4 +53,4 @@ else exit 1 fi -./compile.sh && ./watch.sh && ./lock.sh && ./suffix.sh && ./format.sh && ./clean.sh && ./experimental.sh && ./experimental-invalid.sh && ./compiler-args.sh +./compile.sh && ./watch.sh && ./lock.sh && ./suffix.sh && ./format.sh && ./clean.sh && ./experimental.sh && ./experimental-invalid.sh && ./compiler-args.sh && ./mcp.sh diff --git a/tests/build_tests/suggested_actions/README.md b/tests/build_tests/suggested_actions/README.md new file mode 100644 index 0000000000..656adb34cb --- /dev/null +++ b/tests/build_tests/suggested_actions/README.md @@ -0,0 +1,4 @@ +Run `node ./tests/build_tests/suggested_actions/input.js` to check the tests. + +Run `node ./tests/build_tests/suggested_actions/input.js update` to update snapshots. + diff --git a/tests/build_tests/suggested_actions/expected/current.res.expected b/tests/build_tests/suggested_actions/expected/current.res.expected new file mode 100644 index 0000000000..05942444bc --- /dev/null +++ b/tests/build_tests/suggested_actions/expected/current.res.expected @@ -0,0 +1,14 @@ + + + Applies all automatic migrations in the current file. + + perform-action tests/build_tests/suggested_actions/fixtures/current.res "ApplyAutomaticMigrationsForCurrentFile" + + + + Applies all automatic migrations in the project. + + perform-action tests/build_tests/suggested_actions/fixtures/current.res "ApplyAutomaticMigrationsForFullProject" + + + \ No newline at end of file diff --git a/tests/build_tests/suggested_actions/fixtures/current.res b/tests/build_tests/suggested_actions/fixtures/current.res new file mode 100644 index 0000000000..fda62cdfae --- /dev/null +++ b/tests/build_tests/suggested_actions/fixtures/current.res @@ -0,0 +1 @@ +let _ = Js.Option.map(x => x, Some(1)) diff --git a/tests/build_tests/suggested_actions/input.js b/tests/build_tests/suggested_actions/input.js new file mode 100644 index 0000000000..4481bf653a --- /dev/null +++ b/tests/build_tests/suggested_actions/input.js @@ -0,0 +1,68 @@ +// @ts-check + +import { readdirSync } from "node:fs"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { setup } from "#dev/process"; +import { normalizeNewlines } from "#dev/utils"; + +const { bsc } = setup(import.meta.dirname); + +const expectedDir = path.join(import.meta.dirname, "expected"); + +const fixtures = readdirSync(path.join(import.meta.dirname, "fixtures")).filter( + fileName => path.extname(fileName) === ".res", +); + +const prefix = ["-w", "+A", "-bs-jsx", "4", "-bs-cmi-only", "-llm-mode"]; + +const updateTests = process.argv[2] === "update"; + +/** + * @param {string} output + * @return {string} + */ +function postProcessStdout(output) { + let result = output; + result = result.trimEnd(); + // Normalize absolute paths in suggested_actions to stable project-relative ones + result = result.replace( + /(?:[A-Z]:)?[\\/][^\n]*?tests[\\/]build_tests[\\/]suggested_actions[\\/]/g, + "tests/build_tests/suggested_actions/", + ); + return normalizeNewlines(result); +} + +let doneTasksCount = 0; +let atLeastOneTaskFailed = false; + +for (const fileName of fixtures) { + const fullFilePath = path.join(import.meta.dirname, "fixtures", fileName); + const { stdout } = await bsc([...prefix, fullFilePath]); + doneTasksCount++; + + const actualOutput = postProcessStdout(stdout.toString()); + const expectedFilePath = path.join(expectedDir, `${fileName}.expected`); + if (updateTests) { + await fs.mkdir(expectedDir, { recursive: true }); + await fs.writeFile(expectedFilePath, actualOutput); + } else { + const expectedOutput = postProcessStdout( + await fs.readFile(expectedFilePath, "utf-8"), + ); + if (expectedOutput !== actualOutput) { + console.error( + `The old and new stdout for the test ${fullFilePath} aren't the same`, + ); + console.error("\n=== Old:"); + console.error(expectedOutput); + console.error("\n=== New:"); + console.error(actualOutput); + atLeastOneTaskFailed = true; + } + + if (doneTasksCount === fixtures.length && atLeastOneTaskFailed) { + process.exit(1); + } + } +}