diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ff23e2329..de6f499274 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ # 12.0.0-beta.1 (Unreleased) +#### :rocket: New Feature + +- Add experimental command to `rescript-tools` for formatting all ReScript code blocks in markdown. Either in a markdown file directly, or inside of docstrings in ReScript code. https://github.com/rescript-lang/rescript/pull/7598 + # 12.0.0-alpha.15 #### :boom: Breaking Change diff --git a/analysis/src/Commands.ml b/analysis/src/Commands.ml index 464b3fa53d..9d529dc6c3 100644 --- a/analysis/src/Commands.ml +++ b/analysis/src/Commands.ml @@ -282,17 +282,13 @@ let format ~path = ~filename:path in if List.length diagnostics > 0 then "" - else - Res_printer.print_implementation - ~width:Res_multi_printer.default_print_width ~comments structure + else Res_printer.print_implementation ~comments structure else if Filename.check_suffix path ".resi" then let {Res_driver.parsetree = signature; comments; diagnostics} = Res_driver.parsing_engine.parse_interface ~for_printer:true ~filename:path in if List.length diagnostics > 0 then "" - else - Res_printer.print_interface ~width:Res_multi_printer.default_print_width - ~comments signature + else Res_printer.print_interface ~comments signature else "" let diagnosticSyntax ~path = diff --git a/analysis/src/CreateInterface.ml b/analysis/src/CreateInterface.ml index 6aa1ea1993..5cfd2c2d88 100644 --- a/analysis/src/CreateInterface.ml +++ b/analysis/src/CreateInterface.ml @@ -102,7 +102,7 @@ let printSignature ~extractor ~signature = Printtyp.reset_names (); let sigItemToString (item : Outcometree.out_sig_item) = item |> Res_outcome_printer.print_out_sig_item_doc - |> Res_doc.to_string ~width:Res_multi_printer.default_print_width + |> Res_doc.to_string ~width:Res_printer.default_print_width in let genSigStrForInlineAttr lines attributes id vd = diff --git a/analysis/src/Xform.ml b/analysis/src/Xform.ml index 4d62a9d68f..ddf783c559 100644 --- a/analysis/src/Xform.ml +++ b/analysis/src/Xform.ml @@ -855,7 +855,6 @@ let parseImplementation ~filename = let structure = [Ast_helper.Str.eval ~loc:expr.pexp_loc expr] in structure |> Res_printer.print_implementation - ~width:Res_multi_printer.default_print_width ~comments:(comments |> filterComments ~loc:expr.pexp_loc) |> Utils.indent range.start.character in @@ -864,14 +863,12 @@ let parseImplementation ~filename = let structure = [item] in structure |> Res_printer.print_implementation - ~width:Res_multi_printer.default_print_width ~comments:(comments |> filterComments ~loc:item.pstr_loc) |> Utils.indent range.start.character in let printStandaloneStructure ~(loc : Location.t) structure = structure |> Res_printer.print_implementation - ~width:Res_multi_printer.default_print_width ~comments:(comments |> filterComments ~loc) in (structure, printExpr, printStructureItem, printStandaloneStructure) @@ -891,7 +888,7 @@ let parseInterface ~filename = (item : Parsetree.signature_item) = let signature_item = [item] in signature_item - |> Res_printer.print_interface ~width:Res_multi_printer.default_print_width + |> Res_printer.print_interface ~comments:(comments |> filterComments ~loc:item.psig_loc) |> Utils.indent range.start.character in diff --git a/compiler/ml/location.ml b/compiler/ml/location.ml index 87592822e8..19de2b7125 100644 --- a/compiler/ml/location.ml +++ b/compiler/ml/location.ml @@ -231,24 +231,29 @@ let error_of_exn exn = (* taken from https://github.com/rescript-lang/ocaml/blob/d4144647d1bf9bc7dc3aadc24c25a7efa3a67915/parsing/location.ml#L380 *) (* This is the error report entry point. We'll replace the default reporter with this one. *) -let rec default_error_reporter ?(src = None) ppf {loc; msg; sub} = +let rec default_error_reporter ?(custom_intro = None) ?(src = None) ppf + {loc; msg; sub} = setup_colors (); (* open a vertical box. Everything in our message is indented 2 spaces *) (* If src is given, it will display a syntax error after parsing. *) let intro = - match src with - | Some _ -> "Syntax error!" - | None -> "We've found a bug for you!" + match (custom_intro, src) with + | Some intro, _ -> intro + | None, Some _ -> "Syntax error!" + | None, None -> "We've found a bug for you!" in Format.fprintf ppf "@[@, %a@, %s@,@]" (print ~src ~message_kind:`error intro) loc msg; - List.iter (Format.fprintf ppf "@,@[%a@]" (default_error_reporter ~src)) sub + List.iter + (Format.fprintf ppf "@,@[%a@]" (default_error_reporter ~custom_intro ~src)) + sub (* no need to flush here; location's report_exception (which uses this ultimately) flushes *) let error_reporter = ref default_error_reporter -let report_error ?(src = None) ppf err = !error_reporter ~src ppf err +let report_error ?(custom_intro = None) ?(src = None) ppf err = + !error_reporter ~custom_intro ~src ppf err let error_of_printer loc print x = errorf ~loc "%a@?" print x @@ -276,7 +281,8 @@ let rec report_exception_rec n ppf exn = match error_of_exn exn with | None -> reraise exn | Some `Already_displayed -> () - | Some (`Ok err) -> fprintf ppf "@[%a@]@." (report_error ~src:None) err + | Some (`Ok err) -> + fprintf ppf "@[%a@]@." (report_error ~custom_intro:None ~src:None) err with exn when n > 0 -> report_exception_rec (n - 1) ppf exn let report_exception ppf exn = report_exception_rec 5 ppf exn diff --git a/compiler/ml/location.mli b/compiler/ml/location.mli index 0df157efcc..49758de42a 100644 --- a/compiler/ml/location.mli +++ b/compiler/ml/location.mli @@ -103,12 +103,28 @@ val register_error_of_exn : (exn -> error option) -> unit a location, a message, and optionally sub-messages (each of them being located as well). *) -val report_error : ?src:string option -> formatter -> error -> unit - -val error_reporter : (?src:string option -> formatter -> error -> unit) ref +val report_error : + ?custom_intro:string option -> + ?src:string option -> + formatter -> + error -> + unit + +val error_reporter : + (?custom_intro:string option -> + ?src:string option -> + formatter -> + error -> + unit) + ref (** Hook for intercepting error reports. *) -val default_error_reporter : ?src:string option -> formatter -> error -> unit +val default_error_reporter : + ?custom_intro:string option -> + ?src:string option -> + formatter -> + error -> + unit (** Original error reporter for use in hooks. *) val report_exception : formatter -> exn -> unit diff --git a/compiler/syntax/src/res_diagnostics.ml b/compiler/syntax/src/res_diagnostics.ml index 7fd3b4df04..4b6ad8ca69 100644 --- a/compiler/syntax/src/res_diagnostics.ml +++ b/compiler/syntax/src/res_diagnostics.ml @@ -131,12 +131,13 @@ let explain t = let make ~start_pos ~end_pos category = {start_pos; end_pos; category} -let print_report diagnostics src = +let print_report ?(custom_intro = None) ?(formatter = Format.err_formatter) + diagnostics src = let rec print diagnostics src = match diagnostics with | [] -> () | d :: rest -> - Location.report_error ~src:(Some src) Format.err_formatter + Location.report_error ~custom_intro ~src:(Some src) formatter Location. { loc = @@ -147,12 +148,12 @@ let print_report diagnostics src = }; (match rest with | [] -> () - | _ -> Format.fprintf Format.err_formatter "@."); + | _ -> Format.fprintf formatter "@."); print rest src in - Format.fprintf Format.err_formatter "@["; + Format.fprintf formatter "@["; print (List.rev diagnostics) src; - Format.fprintf Format.err_formatter "@]@." + Format.fprintf formatter "@]@." let unexpected token context = Unexpected {token; context} diff --git a/compiler/syntax/src/res_diagnostics.mli b/compiler/syntax/src/res_diagnostics.mli index 4fd9155665..694788ac4b 100644 --- a/compiler/syntax/src/res_diagnostics.mli +++ b/compiler/syntax/src/res_diagnostics.mli @@ -22,4 +22,9 @@ val message : string -> category val make : start_pos:Lexing.position -> end_pos:Lexing.position -> category -> t -val print_report : t list -> string -> unit +val print_report : + ?custom_intro:string option -> + ?formatter:Format.formatter -> + t list -> + string -> + unit diff --git a/compiler/syntax/src/res_multi_printer.ml b/compiler/syntax/src/res_multi_printer.ml index ff877538c2..711241ade5 100644 --- a/compiler/syntax/src/res_multi_printer.ml +++ b/compiler/syntax/src/res_multi_printer.ml @@ -1,5 +1,3 @@ -let default_print_width = 100 - (* print res files to res syntax *) let print_res ~ignore_parse_errors ~is_interface ~filename = if is_interface then ( @@ -9,7 +7,7 @@ let print_res ~ignore_parse_errors ~is_interface ~filename = if parse_result.invalid then ( Res_diagnostics.print_report parse_result.diagnostics parse_result.source; if not ignore_parse_errors then exit 1); - Res_printer.print_interface ~width:default_print_width + Res_printer.print_interface ~width:Res_printer.default_print_width ~comments:parse_result.comments parse_result.parsetree) else let parse_result = @@ -18,7 +16,7 @@ let print_res ~ignore_parse_errors ~is_interface ~filename = if parse_result.invalid then ( Res_diagnostics.print_report parse_result.diagnostics parse_result.source; if not ignore_parse_errors then exit 1); - Res_printer.print_implementation ~width:default_print_width + Res_printer.print_implementation ~width:Res_printer.default_print_width ~comments:parse_result.comments parse_result.parsetree [@@raises exit] diff --git a/compiler/syntax/src/res_multi_printer.mli b/compiler/syntax/src/res_multi_printer.mli index c6b1d07cb8..bb66106900 100644 --- a/compiler/syntax/src/res_multi_printer.mli +++ b/compiler/syntax/src/res_multi_printer.mli @@ -1,5 +1,3 @@ -val default_print_width : int [@@live] - (* Interface to print source code to res. * Takes a filename called "input" and returns the corresponding formatted res syntax *) val print : ?ignore_parse_errors:bool -> string -> string [@@dead "+print"] diff --git a/compiler/syntax/src/res_printer.ml b/compiler/syntax/src/res_printer.ml index 7e2c76a32b..3941c57691 100644 --- a/compiler/syntax/src/res_printer.ml +++ b/compiler/syntax/src/res_printer.ml @@ -5,6 +5,8 @@ module Token = Res_token module Parens = Res_parens module ParsetreeViewer = Res_parsetree_viewer +let default_print_width = 100 + type callback_style = (* regular arrow function, example: `let f = x => x + 1` *) | NoCallback @@ -6055,7 +6057,8 @@ let print_typ_expr t = print_typ_expr ~state:(State.init ()) t let print_expression e = print_expression ~state:(State.init ()) e let print_pattern p = print_pattern ~state:(State.init ()) p -let print_implementation ~width (s : Parsetree.structure) ~comments = +let print_implementation ?(width = default_print_width) + (s : Parsetree.structure) ~comments = let cmt_tbl = CommentTable.make () in CommentTable.walk_structure s cmt_tbl comments; (* CommentTable.log cmt_tbl; *) @@ -6063,7 +6066,8 @@ let print_implementation ~width (s : Parsetree.structure) ~comments = (* Doc.debug doc; *) Doc.to_string ~width doc ^ "\n" -let print_interface ~width (s : Parsetree.signature) ~comments = +let print_interface ?(width = default_print_width) (s : Parsetree.signature) + ~comments = let cmt_tbl = CommentTable.make () in CommentTable.walk_signature s cmt_tbl comments; Doc.to_string ~width (print_signature ~state:(State.init ()) s cmt_tbl) ^ "\n" diff --git a/compiler/syntax/src/res_printer.mli b/compiler/syntax/src/res_printer.mli index c3b95b8e24..33461d7714 100644 --- a/compiler/syntax/src/res_printer.mli +++ b/compiler/syntax/src/res_printer.mli @@ -1,3 +1,5 @@ +val default_print_width : int + val print_type_params : (Parsetree.core_type * Asttypes.variance) list -> Res_comments_table.t -> @@ -18,9 +20,9 @@ val print_structure : Parsetree.structure -> Res_comments_table.t -> Res_doc.t [@@live] val print_implementation : - width:int -> Parsetree.structure -> comments:Res_comment.t list -> string + ?width:int -> Parsetree.structure -> comments:Res_comment.t list -> string val print_interface : - width:int -> Parsetree.signature -> comments:Res_comment.t list -> string + ?width:int -> Parsetree.signature -> comments:Res_comment.t list -> string val print_ident_like : ?allow_uident:bool -> ?allow_hyphen:bool -> string -> Res_doc.t diff --git a/dune-project b/dune-project index 61fbc62cd2..d8e0553c80 100644 --- a/dune-project +++ b/dune-project @@ -34,6 +34,8 @@ (depends (ocaml (>= 4.14)) + (cmarkit + (>= 0.3.0)) (cppo (= 1.8.0)) analysis diff --git a/runtime/Stdlib_Result.resi b/runtime/Stdlib_Result.resi index 6a6a0c2ecc..08644f11c7 100644 --- a/runtime/Stdlib_Result.resi +++ b/runtime/Stdlib_Result.resi @@ -83,10 +83,10 @@ let getOrThrow: result<'a, 'b> => 'a ```rescript let ok = Ok(42) -Result.mapOr(ok, 0, (x) => x / 2) == 21 +Result.mapOr(ok, 0, x => x / 2) == 21 let error = Error("Invalid data") -Result.mapOr(error, 0, (x) => x / 2) == 0 +Result.mapOr(error, 0, x => x / 2) == 0 ``` */ let mapOr: (result<'a, 'c>, 'b, 'a => 'b) => 'b @@ -102,7 +102,7 @@ ordinary value. ## Examples ```rescript -let f = (x) => sqrt(Int.toFloat(x)) +let f = x => sqrt(Int.toFloat(x)) Result.map(Ok(64), f) == Ok(8.0) @@ -119,8 +119,8 @@ unchanged. Function `f` takes a value of the same type as `n` and returns a ## Examples ```rescript -let recip = (x) => - if (x !== 0.0) { +let recip = x => + if x !== 0.0 { Ok(1.0 /. x) } else { Error("Divide by zero") @@ -219,11 +219,11 @@ let mod10cmp = (a, b) => Int.compare(mod(a, 10), mod(b, 10)) Result.compare(Ok(39), Ok(57), mod10cmp) == 1. -Result.compare(Ok(57), Ok(39), mod10cmp) == (-1.) +Result.compare(Ok(57), Ok(39), mod10cmp) == -1. Result.compare(Ok(39), Error("y"), mod10cmp) == 1. -Result.compare(Error("x"), Ok(57), mod10cmp) == (-1.) +Result.compare(Error("x"), Ok(57), mod10cmp) == -1. Result.compare(Error("x"), Error("y"), mod10cmp) == 0. ``` diff --git a/tests/tools_tests/src/docstrings-format/FormatDocstringsTest1.res b/tests/tools_tests/src/docstrings-format/FormatDocstringsTest1.res new file mode 100644 index 0000000000..f9c0a27339 --- /dev/null +++ b/tests/tools_tests/src/docstrings-format/FormatDocstringsTest1.res @@ -0,0 +1,49 @@ +/** +This is the first docstring with unformatted ReScript code. + +```res example +let badly_formatted=(x,y)=>{ +let result=x+y +if result>0{Console.log("positive")}else{Console.log("negative")} +result +} +``` + +And another code block in the same docstring: + +```rescript +type user={name:string,age:int,active:bool} +let createUser=(name,age)=>{name:name,age:age,active:true} +``` +*/ +let testFunction1 = () => "test1" + +module Nested = { + /** + This is a second docstring with different formatting issues. + + But if I add another line here it should be fine. + + ```res info + module UserService={ + let validate=user => user.age>=18 && user.name !== "" + let getName = user=>user.name + } + ``` + */ + let testFunction2 = () => "test2" +} + +/** +Third docstring with array and option types. + +```rescript +let processUsers=(users:array)=>{ +users +->Array.map(user=>{...user,active:false})->Array.filter(u=>u.age>21) +} + +type status=|Loading|Success(string)|Error(option) +``` +*/ +let testFunction3 = () => "test3" diff --git a/tests/tools_tests/src/docstrings-format/FormatDocstringsTest1.resi b/tests/tools_tests/src/docstrings-format/FormatDocstringsTest1.resi new file mode 100644 index 0000000000..da9106fdc9 --- /dev/null +++ b/tests/tools_tests/src/docstrings-format/FormatDocstringsTest1.resi @@ -0,0 +1,49 @@ +/** +This is the first docstring with unformatted ReScript code. + +```rescript +let badly_formatted=(x,y)=>{ +let result=x+y +if result>0{Console.log("positive")}else{Console.log("negative")} +result +} +``` + +And another code block in the same docstring: + +```rescript +type user={name:string,age:int,active:bool} +let createUser=(name,age)=>{name:name,age:age,active:true} +``` +*/ +let testFunction1: unit => string + +module Nested: { + /** + This is a second docstring with different formatting issues. + + But if I add another line here it should be fine. + + ```rescript + module UserService={ + let validate=user => user.age>=18 && user.name !== "" + let getName = user=>user.name + } + ``` +*/ + let testFunction2: unit => string +} + +/** +Third docstring with array and option types. + +```rescript +let processUsers=(users:array)=>{ +users +->Array.map(user=>{...user,active:false})->Array.filter(u=>u.age>21) +} + +type status=|Loading|Success(string)|Error(option) +``` +*/ +let testFunction3: unit => string diff --git a/tests/tools_tests/src/docstrings-format/FormatDocstringsTest2.res b/tests/tools_tests/src/docstrings-format/FormatDocstringsTest2.res new file mode 100644 index 0000000000..3afb1906c8 --- /dev/null +++ b/tests/tools_tests/src/docstrings-format/FormatDocstringsTest2.res @@ -0,0 +1,56 @@ +/** +Testing JSX and more complex formatting scenarios. + +```rescript +let component=()=>{ +
+

{"Title"->React.string}

+ +
+} +``` + +Testing pattern matching and switch expressions: + +```rescript +let handleResult=(result:result)=>{ +switch result { +| Ok(value)=>Console.log(`Success: ${value}`) +| Error(error)=>Console.error(`Error: ${error}`) +} +} +``` +*/ +let testJsx = () => "jsx test" + +/** +Testing function composition and piping. + +```rescript +let processData=(data:array)=>{ +data +->Array.filter(x=>x>0)->Array.map(x=>x*2)->Array.reduce(0,(acc,x)=>acc+x) +} + +let asyncExample=async()=>{ +let data=await fetchData() +let processed=await processData(data) +Console.log(processed) +} +``` +*/ +let testPipes = () => "pipes test" + +/** +Testing resi code blocks. + +```resi +type x=int +let x:int => + string +module M:{let ff: string => int +} + +``` + */ +let testResi = () => true diff --git a/tests/tools_tests/src/docstrings-format/FormatDocstringsTestError.res b/tests/tools_tests/src/docstrings-format/FormatDocstringsTestError.res new file mode 100644 index 0000000000..dda74f297f --- /dev/null +++ b/tests/tools_tests/src/docstrings-format/FormatDocstringsTestError.res @@ -0,0 +1,9 @@ +/** +This docstring has an error. + +```rescript +let name= +let x=12 +``` +*/ +let testJsx = () => "jsx test" diff --git a/tests/tools_tests/src/docstrings-format/FormatRescriptBlocks.md b/tests/tools_tests/src/docstrings-format/FormatRescriptBlocks.md new file mode 100644 index 0000000000..e602b42168 --- /dev/null +++ b/tests/tools_tests/src/docstrings-format/FormatRescriptBlocks.md @@ -0,0 +1,20 @@ +# Format ReScript code blocks + +This markdown file should be formatted. + +This is the first docstring with unformatted ReScript code. + +```rescript +let badly_formatted=(x,y)=>{ +let result=x+y +if result>0{Console.log("positive")}else{Console.log("negative")} +result +} +``` + +And another code block in the same docstring: + +```res +type user={name:string,age:int,active:bool} +let createUser=(name,age)=>{name:name,age:age,active:true} +``` diff --git a/tests/tools_tests/src/expected/FormatDocstringsTest1.res.expected b/tests/tools_tests/src/expected/FormatDocstringsTest1.res.expected new file mode 100644 index 0000000000..99c9a398f3 --- /dev/null +++ b/tests/tools_tests/src/expected/FormatDocstringsTest1.res.expected @@ -0,0 +1,55 @@ +/** +This is the first docstring with unformatted ReScript code. + +```res example +let badly_formatted = (x, y) => { + let result = x + y + if result > 0 { + Console.log("positive") + } else { + Console.log("negative") + } + result +} +``` + +And another code block in the same docstring: + +```rescript +type user = {name: string, age: int, active: bool} +let createUser = (name, age) => {name, age, active: true} +``` +*/ +let testFunction1 = () => "test1" + +module Nested = { + /** + This is a second docstring with different formatting issues. + + But if I add another line here it should be fine. + + ```res info + module UserService = { + let validate = user => user.age >= 18 && user.name !== "" + let getName = user => user.name + } + ``` + */ + let testFunction2 = () => "test2" +} + +/** +Third docstring with array and option types. + +```rescript +let processUsers = (users: array) => { + users + ->Array.map(user => {...user, active: false}) + ->Array.filter(u => u.age > 21) +} + +type status = Loading | Success(string) | Error(option) +``` +*/ +let testFunction3 = () => "test3" + diff --git a/tests/tools_tests/src/expected/FormatDocstringsTest1.resi.expected b/tests/tools_tests/src/expected/FormatDocstringsTest1.resi.expected new file mode 100644 index 0000000000..5a46d5fe2a --- /dev/null +++ b/tests/tools_tests/src/expected/FormatDocstringsTest1.resi.expected @@ -0,0 +1,55 @@ +/** +This is the first docstring with unformatted ReScript code. + +```rescript +let badly_formatted = (x, y) => { + let result = x + y + if result > 0 { + Console.log("positive") + } else { + Console.log("negative") + } + result +} +``` + +And another code block in the same docstring: + +```rescript +type user = {name: string, age: int, active: bool} +let createUser = (name, age) => {name, age, active: true} +``` +*/ +let testFunction1: unit => string + +module Nested: { + /** + This is a second docstring with different formatting issues. + + But if I add another line here it should be fine. + + ```rescript + module UserService = { + let validate = user => user.age >= 18 && user.name !== "" + let getName = user => user.name + } + ``` +*/ + let testFunction2: unit => string +} + +/** +Third docstring with array and option types. + +```rescript +let processUsers = (users: array) => { + users + ->Array.map(user => {...user, active: false}) + ->Array.filter(u => u.age > 21) +} + +type status = Loading | Success(string) | Error(option) +``` +*/ +let testFunction3: unit => string + diff --git a/tests/tools_tests/src/expected/FormatDocstringsTest2.res.expected b/tests/tools_tests/src/expected/FormatDocstringsTest2.res.expected new file mode 100644 index 0000000000..53f7fe5619 --- /dev/null +++ b/tests/tools_tests/src/expected/FormatDocstringsTest2.res.expected @@ -0,0 +1,58 @@ +/** +Testing JSX and more complex formatting scenarios. + +```rescript +let component = () => { +
+

{"Title"->React.string}

+ +
+} +``` + +Testing pattern matching and switch expressions: + +```rescript +let handleResult = (result: result) => { + switch result { + | Ok(value) => Console.log(`Success: ${value}`) + | Error(error) => Console.error(`Error: ${error}`) + } +} +``` +*/ +let testJsx = () => "jsx test" + +/** +Testing function composition and piping. + +```rescript +let processData = (data: array) => { + data + ->Array.filter(x => x > 0) + ->Array.map(x => x * 2) + ->Array.reduce(0, (acc, x) => acc + x) +} + +let asyncExample = async () => { + let data = await fetchData() + let processed = await processData(data) + Console.log(processed) +} +``` +*/ +let testPipes = () => "pipes test" + +/** +Testing resi code blocks. + +```resi +type x = int +let x: int => string +module M: { + let ff: string => int +} +``` + */ +let testResi = () => true + diff --git a/tests/tools_tests/src/expected/FormatDocstringsTestError.res.expected b/tests/tools_tests/src/expected/FormatDocstringsTestError.res.expected new file mode 100644 index 0000000000..c5b52e6828 --- /dev/null +++ b/tests/tools_tests/src/expected/FormatDocstringsTestError.res.expected @@ -0,0 +1,12 @@ + + Syntax error in code block in docstring + FormatDocstringsTestError.res:5:10-6:3 + + 3 │ + 4 │ + 5 │ let name= + 6 │ let x=12 + + This let-binding misses an expression + + diff --git a/tests/tools_tests/src/expected/FormatRescriptBlocks.md.expected b/tests/tools_tests/src/expected/FormatRescriptBlocks.md.expected new file mode 100644 index 0000000000..43a09d84c8 --- /dev/null +++ b/tests/tools_tests/src/expected/FormatRescriptBlocks.md.expected @@ -0,0 +1,25 @@ +# Format ReScript code blocks + +This markdown file should be formatted. + +This is the first docstring with unformatted ReScript code. + +```rescript +let badly_formatted = (x, y) => { + let result = x + y + if result > 0 { + Console.log("positive") + } else { + Console.log("negative") + } + result +} +``` + +And another code block in the same docstring: + +```res +type user = {name: string, age: int, active: bool} +let createUser = (name, age) => {name, age, active: true} +``` + diff --git a/tests/tools_tests/test.sh b/tests/tools_tests/test.sh index cd1b32571a..7289d651c8 100755 --- a/tests/tools_tests/test.sh +++ b/tests/tools_tests/test.sh @@ -16,6 +16,16 @@ for file in ppx/*.res; do fi done +# Test format-docstrings command +for file in src/docstrings-format/*.{res,resi,md}; do + output="src/expected/$(basename $file).expected" + ../../_build/install/default/bin/rescript-tools format-codeblocks "$file" --stdout > $output + # # CI. We use LF, and the CI OCaml fork prints CRLF. Convert. + if [ "$RUNNER_OS" == "Windows" ]; then + perl -pi -e 's/\r\n/\n/g' -- $output + fi +done + warningYellow='\033[0;33m' successGreen='\033[0;32m' reset='\033[0m' diff --git a/tools.opam b/tools.opam index 48333848f1..4e31f92595 100644 --- a/tools.opam +++ b/tools.opam @@ -8,6 +8,7 @@ homepage: "https://github.com/rescript-lang/rescript-compiler" bug-reports: "https://github.com/rescript-lang/rescript-compiler/issues" depends: [ "ocaml" {>= "4.14"} + "cmarkit" {>= "0.3.0"} "cppo" {= "1.8.0"} "analysis" "dune" {>= "3.17"} diff --git a/tools/bin/main.ml b/tools/bin/main.ml index 2d97dea930..da44211ac6 100644 --- a/tools/bin/main.ml +++ b/tools/bin/main.ml @@ -7,6 +7,15 @@ Usage: rescript-tools doc Example: rescript-tools doc ./path/to/EntryPointLib.res|} +let formatDocstringsHelp = + {|ReScript Tools + +Format ReScript code blocks in docstrings + +Usage: rescript-tools format-docstrings [--stdout] [--transform-assert-equal] + +Example: rescript-tools format-docstrings ./path/to/MyModule.res|} + let help = {|ReScript Tools @@ -14,10 +23,13 @@ Usage: rescript-tools [command] Commands: -doc Generate documentation -reanalyze Reanalyze --v, --version Print version --h, --help Print help|} +doc Generate documentation +format-codeblocks Format ReScript code blocks + [--stdout] Output to stdout + [--transform-assert-equal] Transform `assertEqual` to `==` +reanalyze Reanalyze +-v, --version Print version +-h, --help Print help|} let logAndExit = function | Ok log -> @@ -43,6 +55,23 @@ let main () = in logAndExit (Tools.extractDocs ~entryPointFile:path ~debug:false) | _ -> logAndExit (Error docHelp)) + | "format-codeblocks" :: rest -> ( + match rest with + | ["-h"] | ["--help"] -> logAndExit (Ok formatDocstringsHelp) + | path :: args -> ( + let isStdout = List.mem "--stdout" args in + let transformAssertEqual = List.mem "--transform-assert-equal" args in + let outputMode = if isStdout then `Stdout else `File in + Clflags.color := Some Misc.Color.Never; + match + ( Tools.FormatCodeblocks.formatCodeBlocksInFile ~outputMode + ~transformAssertEqual ~entryPointFile:path, + outputMode ) + with + | Ok content, `Stdout -> print_endline content + | result, `File -> logAndExit result + | Error e, _ -> logAndExit (Error e)) + | _ -> logAndExit (Error formatDocstringsHelp)) | "reanalyze" :: _ -> let len = Array.length Sys.argv in for i = 1 to len - 2 do diff --git a/tools/src/dune b/tools/src/dune index f124a3737a..ec90d74ab0 100644 --- a/tools/src/dune +++ b/tools/src/dune @@ -2,4 +2,4 @@ (name tools) (flags (-w "+6+26+27+32+33+39")) - (libraries analysis)) + (libraries analysis cmarkit)) diff --git a/tools/src/tools.ml b/tools/src/tools.ml index 2db1be7e6a..05cc125284 100644 --- a/tools/src/tools.ml +++ b/tools/src/tools.ml @@ -676,3 +676,247 @@ let extractEmbedded ~extensionPoints ~filename = ("loc", Some (Analysis.Utils.cmtLocToRange loc |> stringifyRange)); ]) |> List.rev |> array + +module FormatCodeblocks = struct + module Transform = struct + type transform = AssertEqualFnToEquals (** assertEqual(a, b) -> a == b *) + + (** Transforms for the code blocks themselves. *) + let transform ~transforms ast = + match transforms with + | [] -> ast + | transforms -> + let hasTransform transform = transforms |> List.mem transform in + let mapper = + { + Ast_mapper.default_mapper with + expr = + (fun mapper exp -> + match exp.pexp_desc with + | Pexp_apply + { + funct = + { + pexp_desc = + Pexp_ident + ({txt = Lident "assertEqual"} as identTxt); + } as ident; + partial = false; + args = [(Nolabel, _arg1); (Nolabel, _arg2)] as args; + } + when hasTransform AssertEqualFnToEquals -> + { + exp with + pexp_desc = + Pexp_apply + { + funct = + { + ident with + pexp_desc = + Pexp_ident {identTxt with txt = Lident "=="}; + }; + args; + partial = false; + transformed_jsx = false; + }; + } + | _ -> Ast_mapper.default_mapper.expr mapper exp); + } + in + mapper.structure mapper ast + end + + let isResLang lang = + match String.lowercase_ascii lang with + | "res" | "rescript" | "resi" -> true + | lang -> + (* Cover ```res example, and similar *) + String.starts_with ~prefix:"res " lang + || String.starts_with ~prefix:"rescript " lang + || String.starts_with ~prefix:"resi " lang + + let formatRescriptCodeBlocks content ~transformAssertEqual ~displayFilename + ~addError ~markdownBlockStartLine = + (* Detect ReScript code blocks. *) + let hadCodeBlocks = ref false in + let block _m = function + | Cmarkit.Block.Code_block (codeBlock, meta) -> ( + match Cmarkit.Block.Code_block.info_string codeBlock with + | Some ((lang, _) as info_string) when isResLang lang -> + hadCodeBlocks := true; + + let currentLine = + meta |> Cmarkit.Meta.textloc |> Cmarkit.Textloc.first_line |> fst + in + (* Account for 0-based line numbers *) + let currentLine = currentLine + 1 in + let layout = Cmarkit.Block.Code_block.layout codeBlock in + let code = Cmarkit.Block.Code_block.code codeBlock in + let codeText = + code |> List.map Cmarkit.Block_line.to_string |> String.concat "\n" + in + + let n = List.length code in + let newlinesNeeded = + max 0 (markdownBlockStartLine + currentLine - n) + in + let codeWithOffset = String.make newlinesNeeded '\n' ^ codeText in + let reportParseError diagnostics = + let buf = Buffer.create 1000 in + let formatter = Format.formatter_of_buffer buf in + Res_diagnostics.print_report ~formatter + ~custom_intro:(Some "Syntax error in code block in docstring") + diagnostics codeWithOffset; + addError (Buffer.contents buf) + in + let formattedCode = + if lang |> String.split_on_char ' ' |> List.hd = "resi" then + let {Res_driver.parsetree; comments; invalid; diagnostics} = + Res_driver.parse_interface_from_source ~for_printer:true + ~display_filename:displayFilename ~source:codeWithOffset + in + if invalid then ( + reportParseError diagnostics; + code) + else + Res_printer.print_interface parsetree ~comments + |> String.trim |> Cmarkit.Block_line.list_of_string + else + let {Res_driver.parsetree; comments; invalid; diagnostics} = + Res_driver.parse_implementation_from_source ~for_printer:true + ~display_filename:displayFilename ~source:codeWithOffset + in + if invalid then ( + reportParseError diagnostics; + code) + else + let parsetree = + if transformAssertEqual then + Transform.transform ~transforms:[AssertEqualFnToEquals] + parsetree + else parsetree + in + Res_printer.print_implementation parsetree ~comments + |> String.trim |> Cmarkit.Block_line.list_of_string + in + + let mappedCodeBlock = + Cmarkit.Block.Code_block.make ~layout ~info_string formattedCode + in + Cmarkit.Mapper.ret (Cmarkit.Block.Code_block (mappedCodeBlock, meta)) + | _ -> Cmarkit.Mapper.default) + | _ -> Cmarkit.Mapper.default + in + let mapper = Cmarkit.Mapper.make ~block () in + let newContent = + content + |> Cmarkit.Doc.of_string ~locs:true + |> Cmarkit.Mapper.map_doc mapper + |> Cmarkit_commonmark.of_doc + in + (newContent, !hadCodeBlocks) + + let formatCodeBlocksInFile ~outputMode ~transformAssertEqual ~entryPointFile = + let path = + match Filename.is_relative entryPointFile with + | true -> Unix.realpath entryPointFile + | false -> entryPointFile + in + let errors = ref [] in + let addError error = errors := error :: !errors in + + let makeMapper ~transformAssertEqual ~displayFilename = + { + Ast_mapper.default_mapper with + attribute = + (fun mapper ((name, payload) as attr) -> + match (name, Ast_payload.is_single_string payload, payload) with + | ( {txt = "res.doc"}, + Some (contents, None), + PStr [{pstr_desc = Pstr_eval ({pexp_loc}, _)}] ) -> + let formattedContents, hadCodeBlocks = + formatRescriptCodeBlocks ~transformAssertEqual ~addError + ~displayFilename + ~markdownBlockStartLine:pexp_loc.loc_start.pos_lnum contents + in + if hadCodeBlocks && formattedContents <> contents then + ( name, + PStr + [ + Ast_helper.Str.eval + (Ast_helper.Exp.constant + (Pconst_string (formattedContents, None))); + ] ) + else attr + | _ -> Ast_mapper.default_mapper.attribute mapper attr); + } + in + let content = + if Filename.check_suffix path ".md" then + let content = + let ic = open_in path in + let n = in_channel_length ic in + let s = Bytes.create n in + really_input ic s 0 n; + close_in ic; + Bytes.to_string s + in + let displayFilename = Filename.basename path in + let formattedContents, hadCodeBlocks = + formatRescriptCodeBlocks ~transformAssertEqual ~addError + ~displayFilename ~markdownBlockStartLine:1 content + in + if hadCodeBlocks && formattedContents <> content then + Ok (formattedContents, content) + else Ok (content, content) + else if Filename.check_suffix path ".res" then + let parser = + Res_driver.parsing_engine.parse_implementation ~for_printer:true + in + let {Res_driver.parsetree = structure; comments; source; filename} = + parser ~filename:path + in + let filename = Filename.basename filename in + let mapper = + makeMapper ~transformAssertEqual ~displayFilename:filename + in + let astMapped = mapper.structure mapper structure in + Ok (Res_printer.print_implementation astMapped ~comments, source) + else if Filename.check_suffix path ".resi" then + let parser = + Res_driver.parsing_engine.parse_interface ~for_printer:true + in + let {Res_driver.parsetree = signature; comments; source; filename} = + parser ~filename:path + in + let mapper = + makeMapper ~transformAssertEqual ~displayFilename:filename + in + let astMapped = mapper.signature mapper signature in + Ok (Res_printer.print_interface astMapped ~comments, source) + else + Error + (Printf.sprintf + "File extension not supported. This command accepts .res, .resi, \ + and .md files") + in + match content with + | Error e -> Error e + | Ok (formatted_content, source) -> + let errors = !errors in + if List.length errors > 0 then ( + errors |> List.rev |> String.concat "\n" |> print_endline; + Error + (Printf.sprintf "%s: Error formatting docstrings." + (Filename.basename path))) + else if formatted_content <> source then ( + match outputMode with + | `Stdout -> Ok formatted_content + | `File -> + let oc = open_out path in + Printf.fprintf oc "%s" formatted_content; + close_out oc; + Ok (Filename.basename path ^ ": formatted successfully")) + else Ok (Filename.basename path ^ ": needed no formatting") +end