From d9699998acb484e36aadbe2b4da2c1e2198f4105 Mon Sep 17 00:00:00 2001 From: Gabriel Nordeborn Date: Fri, 17 Apr 2026 17:29:59 +0200 Subject: [PATCH 01/17] step 1: ai lint command with a few simple rules --- compiler/ml/code_frame.ml | 251 ++++++---- compiler/ml/location.ml | 6 +- docs/rescript_ai.md | 439 +++++++++++++++++ tests/tools_tests/.rescript-lint.json | 11 + .../src/expected/Clean.res.lint.expected | 0 .../src/expected/Clean.res.lint.json.expected | 1 + .../ForbiddenExplicit.res.lint.expected | 11 + .../ForbiddenExplicit.res.lint.json.expected | 1 + .../ForbiddenExplicitNoCmt.res.lint.expected | 0 ...biddenExplicitNoCmt.res.lint.json.expected | 1 + .../expected/ForbiddenOpen.res.lint.expected | 12 + .../ForbiddenOpen.res.lint.json.expected | 1 + .../expected/ForbiddenType.res.lint.expected | 11 + .../ForbiddenType.res.lint.json.expected | 1 + .../src/expected/SingleUse.res.lint.expected | 13 + .../expected/SingleUse.res.lint.json.expected | 1 + .../src/expected/lint-root.expected | 50 ++ .../src/expected/lint-root.json.expected | 1 + tests/tools_tests/src/lint/Clean.res | 1 + .../src/lint/ForbiddenExplicit.res | 1 + tests/tools_tests/src/lint/ForbiddenOpen.res | 3 + tests/tools_tests/src/lint/ForbiddenType.res | 1 + tests/tools_tests/src/lint/SingleUse.res | 4 + tests/tools_tests/test.sh | 38 ++ .../unbuilt/ForbiddenExplicitNoCmt.res | 1 + tools/bin/main.ml | 38 ++ tools/src/lint.ml | 22 + tools/src/lint_analysis.ml | 460 ++++++++++++++++++ tools/src/lint_config.ml | 88 ++++ tools/src/lint_output.ml | 85 ++++ tools/src/lint_shared.ml | 67 +++ tools/src/lint_support.ml | 63 +++ tools/src/tools.ml | 1 + 33 files changed, 1590 insertions(+), 94 deletions(-) create mode 100644 docs/rescript_ai.md create mode 100644 tests/tools_tests/.rescript-lint.json create mode 100644 tests/tools_tests/src/expected/Clean.res.lint.expected create mode 100644 tests/tools_tests/src/expected/Clean.res.lint.json.expected create mode 100644 tests/tools_tests/src/expected/ForbiddenExplicit.res.lint.expected create mode 100644 tests/tools_tests/src/expected/ForbiddenExplicit.res.lint.json.expected create mode 100644 tests/tools_tests/src/expected/ForbiddenExplicitNoCmt.res.lint.expected create mode 100644 tests/tools_tests/src/expected/ForbiddenExplicitNoCmt.res.lint.json.expected create mode 100644 tests/tools_tests/src/expected/ForbiddenOpen.res.lint.expected create mode 100644 tests/tools_tests/src/expected/ForbiddenOpen.res.lint.json.expected create mode 100644 tests/tools_tests/src/expected/ForbiddenType.res.lint.expected create mode 100644 tests/tools_tests/src/expected/ForbiddenType.res.lint.json.expected create mode 100644 tests/tools_tests/src/expected/SingleUse.res.lint.expected create mode 100644 tests/tools_tests/src/expected/SingleUse.res.lint.json.expected create mode 100644 tests/tools_tests/src/expected/lint-root.expected create mode 100644 tests/tools_tests/src/expected/lint-root.json.expected create mode 100644 tests/tools_tests/src/lint/Clean.res create mode 100644 tests/tools_tests/src/lint/ForbiddenExplicit.res create mode 100644 tests/tools_tests/src/lint/ForbiddenOpen.res create mode 100644 tests/tools_tests/src/lint/ForbiddenType.res create mode 100644 tests/tools_tests/src/lint/SingleUse.res create mode 100644 tests/tools_tests/unbuilt/ForbiddenExplicitNoCmt.res create mode 100644 tools/src/lint.ml create mode 100644 tools/src/lint_analysis.ml create mode 100644 tools/src/lint_config.ml create mode 100644 tools/src/lint_output.ml create mode 100644 tools/src/lint_shared.ml create mode 100644 tools/src/lint_support.ml diff --git a/compiler/ml/code_frame.ml b/compiler/ml/code_frame.ml index 9f75c765ac4..39916bbcaea 100644 --- a/compiler/ml/code_frame.ml +++ b/compiler/ml/code_frame.ml @@ -4,10 +4,11 @@ let digits_count n = in loop (abs n) 1 0 -let seek_2_lines_before src (pos : Lexing.position) = +let seek_lines_before line_count src (pos : Lexing.position) = let original_line = pos.pos_lnum in let rec loop current_line current_char = - if current_line + 2 >= original_line then (current_char, current_line) + if current_line + line_count >= original_line then + (current_char, current_line) else loop (if src.[current_char] = '\n' then current_line + 1 else current_line) @@ -15,13 +16,13 @@ let seek_2_lines_before src (pos : Lexing.position) = in loop 1 0 -let seek_2_lines_after src (pos : Lexing.position) = +let seek_lines_after line_count src (pos : Lexing.position) = let original_line = pos.pos_lnum in let rec loop current_line current_char = if current_char = String.length src then (current_char, current_line) else match src.[current_char] with - | '\n' when current_line = original_line + 2 -> + | '\n' when current_line = original_line + line_count -> (current_char, current_line) | '\n' -> loop (current_line + 1) (current_char + 1) | _ -> loop current_line (current_char + 1) @@ -60,6 +61,13 @@ let filter_mapi f l = in loop f l 0 [] |> List.rev +let maybe_drop_trailing_blank_line ~skip_blank_context lines = + if skip_blank_context then + match List.rev lines with + | "" :: rest -> List.rev rest + | _ -> lines + else lines + (* Spiritual equivalent of https://github.com/ocaml/ocaml/blob/414bdec9ae387129b8102cc6bf3c0b6ae173eeb9/utils/misc.ml#L601 *) @@ -107,6 +115,7 @@ let setup = Color.setup type gutter = Number of int | Elided type highlighted_string = {s: string; start: int; end_: int} type line = {gutter: gutter; content: highlighted_string list} +type highlight_style = Flat | Colored | Underlined (* Features: @@ -116,16 +125,17 @@ 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 ~highlight_style ~context_lines_before ~context_lines_after + ~skip_blank_context ~is_warning ~src ~(start_pos : Lexing.position) ~(end_pos : Lexing.position) = let indent = 2 in let highlight_line_start_line = start_pos.pos_lnum in let highlight_line_end_line = end_pos.pos_lnum in let start_line_line_offset, first_shown_line = - seek_2_lines_before src start_pos + seek_lines_before context_lines_before src start_pos in let end_line_line_end_offset, last_shown_line = - seek_2_lines_after src end_pos + seek_lines_after context_lines_after src end_pos in let more_than_5_highlighted_lines = @@ -136,25 +146,36 @@ let print ~is_warning ~src ~(start_pos : Lexing.position) (* 3 for separator + the 2 spaces around it *) let line_width = 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 [] + (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' + |> maybe_drop_trailing_blank_line ~skip_blank_context + |> 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 []) + |> + if skip_blank_context then + List.filter (fun (gutter, line) -> + match gutter with + | Elided -> true + | Number line_number -> + line_number >= highlight_line_start_line + && line_number <= highlight_line_end_line + || String.trim line <> "") + else fun lines -> lines in let leading_space_to_cut = lines @@ -214,71 +235,117 @@ let print ~is_warning ~src ~(start_pos : Lexing.position) in {gutter; content = new_content}) in - let buf = Buffer.create 100 in - let open Color in - let add_ch = - let last_color = ref NoColor in - fun color ch -> - if (not !Color.color_enabled) || !last_color = color then - Buffer.add_char buf ch - else - let ansi = - match (!last_color, color) with - | NoColor, Dim -> dim - (* | NoColor, Filename -> filename *) - | NoColor, Err -> err - | NoColor, Warn -> warn - | _, NoColor -> reset - | _, Dim -> reset ^ dim - (* | _, Filename -> reset ^ filename *) - | _, Err -> reset ^ err - | _, Warn -> reset ^ warn - in - Buffer.add_string buf ansi; - Buffer.add_char buf ch; - last_color := color - in - let draw_gutter color s = - for _i = 1 to max_line_digits_count + indent - String.length s do + let is_highlighted_segment {start; end_; _} = end_ > start in + let render_inline ~use_color () = + let buf = Buffer.create 100 in + let open Color in + let add_ch = + let last_color = ref NoColor in + fun color ch -> + if (not use_color) || (not !Color.color_enabled) || !last_color = color + then Buffer.add_char buf ch + else + let ansi = + match (!last_color, color) with + | NoColor, Dim -> dim + (* | NoColor, Filename -> filename *) + | NoColor, Err -> err + | NoColor, Warn -> warn + | _, NoColor -> reset + | _, Dim -> reset ^ dim + (* | _, Filename -> reset ^ filename *) + | _, Err -> reset ^ err + | _, Warn -> reset ^ warn + in + Buffer.add_string buf ansi; + Buffer.add_char buf ch; + last_color := color + in + let draw_gutter color s = + for _i = 1 to max_line_digits_count + indent - String.length s do + add_ch NoColor ' ' + done; + s |> String.iter (add_ch color); + add_ch NoColor ' '; + separator |> String.iter (add_ch Dim); add_ch NoColor ' ' - done; - s |> String.iter (add_ch color); - add_ch NoColor ' '; - separator |> String.iter (add_ch Dim); - add_ch NoColor ' ' - in - stripped_lines - |> List.iter (fun {gutter; content} -> - match gutter with - | Elided -> - draw_gutter Dim "."; - add_ch Dim '.'; - add_ch Dim '.'; - add_ch Dim '.'; - add_ch NoColor '\n' - | Number line_number -> - content - |> List.iteri (fun i line -> - let gutter_content = - if i = 0 then string_of_int line_number else "" - in - let gutter_color = - if - i = 0 - && line_number >= highlight_line_start_line - && line_number <= highlight_line_end_line - then if is_warning then Warn else Err - else NoColor - in - draw_gutter gutter_color gutter_content; + in + stripped_lines + |> List.iter (fun {gutter; content} -> + match gutter with + | Elided -> + draw_gutter Dim "."; + add_ch Dim '.'; + add_ch Dim '.'; + add_ch Dim '.'; + add_ch NoColor '\n' + | Number line_number -> + content + |> List.iteri (fun i line -> + let gutter_content = + if i = 0 then string_of_int line_number else "" + in + let gutter_color = + if + i = 0 + && line_number >= highlight_line_start_line + && line_number <= highlight_line_end_line + then if is_warning then Warn else Err + else NoColor + in + draw_gutter gutter_color gutter_content; - line.s - |> String.iteri (fun ii ch -> - let c = - if ii >= line.start && ii < line.end_ then - if is_warning then Warn else Err - else NoColor - in - add_ch c ch); - add_ch NoColor '\n')); - Buffer.contents buf + line.s + |> String.iteri (fun ii ch -> + let c = + if ii >= line.start && ii < line.end_ then + if is_warning then Warn else Err + else NoColor + in + add_ch c ch); + add_ch NoColor '\n')); + Buffer.contents buf + in + let render_underlined () = + let buf = Buffer.create 100 in + let draw_gutter marker gutter_content = + Buffer.add_string buf + (Printf.sprintf "%s %*s | " marker max_line_digits_count gutter_content) + in + let draw_caret_line caret_start caret_end = + let caret_start = max 0 caret_start in + let caret_end = max caret_start caret_end in + let caret_count = max 1 (caret_end - caret_start) in + draw_gutter " " ""; + Buffer.add_string buf (String.make caret_start ' '); + Buffer.add_string buf (String.make caret_count '^'); + Buffer.add_char buf '\n' + in + stripped_lines + |> List.iter (fun {gutter; content} -> + match gutter with + | Elided -> + draw_gutter " " "."; + Buffer.add_string buf "..."; + Buffer.add_char buf '\n' + | Number line_number -> + content + |> List.iteri (fun i line -> + let gutter_content = + if i = 0 then string_of_int line_number else "" + in + let highlight_this_row = is_highlighted_segment line in + let marker = if highlight_this_row then ">" else " " in + draw_gutter marker gutter_content; + Buffer.add_string buf line.s; + Buffer.add_char buf '\n'; + if highlight_this_row then + draw_caret_line + (min (String.length line.s) line.start) + (min (String.length line.s) line.end_))); + Buffer.contents buf + in + match highlight_style with + | Flat -> render_inline ~use_color:false () + | Colored -> render_inline ~use_color:true () + | Underlined -> render_underlined () diff --git a/compiler/ml/location.ml b/compiler/ml/location.ml index fa2e806db07..6bef3468127 100644 --- a/compiler/ml/location.ml +++ b/compiler/ml/location.ml @@ -140,8 +140,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 ~highlight_style:Colored ~context_lines_before:2 + ~context_lines_after:2 ~skip_blank_context:false + ~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. *) diff --git a/docs/rescript_ai.md b/docs/rescript_ai.md new file mode 100644 index 00000000000..fa48ba99866 --- /dev/null +++ b/docs/rescript_ai.md @@ -0,0 +1,439 @@ +# ReScript AI + +This document captures a first working direction for AI-oriented tooling in ReScript. + +The lint command is the first concrete piece, but the intended surface is broader: commands and output modes in `rescript-tools` that are shaped for LLMs and agent workflows. + +## Goal + +Build AI-oriented tooling that: + +- works well when an AI edits one file and wants immediate feedback +- also works on a whole project or subtree +- can inspect both raw source AST and typed information from `.cmt/.cmti` +- emits compact, reliable output that is easy for an LLM to consume + +The first concrete feature under this umbrella is an AI-oriented lint command. +Aggressive source normalization for agents should be a separate command rather than an extension of lint. + +## First Feature: Lint + +The rest of this document focuses on lint as the first implementation target. + +## Command Surface + +Recommended first shape: + +```sh +rescript-tools lint [--config ] [--json] +rescript-tools rewrite [--config ] [--json] +``` + +Notes: + +- `lint ` is the primary AI workflow +- `lint ` should walk project sources +- source AST analysis and typed analysis should run together as one lint pass +- text should be the default output +- `--json` should opt into compact JSON +- git-aware filtering is a later extension +- `rewrite` is separate from `lint` +- `rewrite` is allowed to be more aggressive because it is explicitly agent-oriented +- lint reports style/semantic problems; rewrite canonicalizes source into a narrower normal form + +## Recommended Placement + +- CLI entrypoint: `tools/bin/main.ml` +- command implementation: new module in `tools/src/` +- semantic loading and package resolution: reuse `analysis/src/Cmt.ml` +- typedtree-derived structure/reference data: reuse `analysis/src/ProcessCmt.ml` +- compact JSON helpers: likely reuse or extend `analysis/src/Protocol.ml` + +## Lint Analysis Model + +Use two analysis lanes with one shared finding type, but expose them as one command rather than separate modes. + +### 1. AST lane + +Input: raw source file. + +Use this for: + +- fast per-file checks +- pre-build use +- syntax-shaped rules +- parse and structural checks that do not depend on semantic artifacts + +Implementation should reuse the existing parser path already used in `tools/src/tools.ml`. + +### 2. Typed lane + +Input: `.cmt/.cmti`. + +Use this for: + +- resolved references +- handling `open`s and aliases correctly +- distinguishing module/type/value usage precisely +- future semantic queries like search/hover-like lookups + +This lane should be the source of truth for semantic rules, while still running alongside the AST lane in the same invocation. + +### Shared finding type + +Proposed fields: + +- `rule` +- `path` +- `range` +- `severity` +- `message` +- `symbol` +- `snippet` in text output when useful + +Example JSON finding: + +```json +{"rule":"forbidden-reference","path":"src/A.res","range":[12,2,12,20],"severity":"error","symbol":"Belt.Array.forEach","message":"Forbidden reference"} +``` + +## Lint Output Contract + +Primary goal: compact and deterministic output for AI. + +Recommended behavior: + +- text by default +- `--json` for machine-readable compact JSON +- stable field names +- no extra prose in either mode +- exit `0` when clean, `1` when findings exist, `2` on usage/internal failure + +Recommended default shape: + +````text +severity: error +rule: forbidden-reference +path: src/A.res +range: 13:3-13:21 +message: Forbidden reference +symbol: Belt.Array.forEach +snippet: +```text + 12 | let values = [1, 2, 3] +> 13 | Belt.Array.forEach(values, value => Console.log(value)) + | ^^^^^^^^^^^^^^^^^^ + 14 | let done = true +``` +```` + +## Lint Config + +Recommendation: keep lint config outside `rescript.json`. + +Reason: + +- lint rules will likely evolve faster than core project config +- keeps this feature decoupled from build config +- easier to make AI-specific without polluting `rescript.json` + +Possible file names: + +- `.rescript-lint.json` +- `rescript-lint.json` + +Example shape: + +```json +{ + "rules": { + "forbidden-reference": { + "severity": "error", + "items": [ + "RescriptCore", + "Belt", + "Belt.Array.forEach" + ] + }, + "single-use-function": { + "severity": "warning" + } + } +} +``` + +## Lint V1 Rules + +### Forbidden references + +Support banning references to: + +- modules +- namespaces +- types +- values/functions +- deep member paths + +Examples: + +- `RescriptCore` +- `Belt` +- `Belt.Array.forEach` + +Implementation notes: + +- typed mode should resolve real referenced symbols and paths +- config items should be resolved once up front to canonical typed paths before matching +- matching should support both exact symbol bans and parent bans + - if `Belt` is banned, `Belt.Array.forEach` should also fail + +### Single-use functions + +Goal: discourage helper functions that are defined once and used once. + +V1 scope should stay intentionally small: + +- same file only +- non-exported values only +- only regular function bindings +- skip cross-file reasoning + +Why: + +- cross-file usage becomes a project analysis problem +- local-only checks get most of the value with much lower complexity + +Likely exclusions for V1: + +- exported bindings +- recursive functions +- callbacks passed inline through transformations that make counting noisy +- generated/PPX-shaped artifacts if they create unstable counts + +Longer term, this can grow into a reanalyze-style map/merge analysis. + +## Separate Command: Agent Rewrite + +Aggressive source normalization for agents should be a separate command rather than part of lint. + +Recommended first shape: + +```sh +rescript-tools rewrite [--config ] [--json] +``` + +Goal: + +- rewrite source into a narrower canonical form for agents +- reduce syntax variety even when multiple source spellings are semantically equivalent +- allow aggressive transformations as long as the rewritten program passes verification + +This is intentionally not just lint autofix. +It is an agent-oriented normalization pass. + +### Rewrite model + +- rewrite to a fixed point until the file stops changing +- use a deterministic pass order so output is stable +- prefer AST-based rewriting and printing over text hacks +- rewrite whole enclosing items when that yields cleaner canonical output +- report which rules fired, which rewrites were skipped, and which were rejected by verification + +### Verification + +Because this mode is intentionally aggressive, it should verify more than a normal lint fix. + +- reparse rewritten source +- re-typecheck rewritten source when semantic artifacts are available +- reject rewrites that fail verification +- optionally compare normalized typed output or generated JS for especially aggressive rules + +### Candidate rewrite rules + +#### Prefer `switch` over `if` / ternary + +Goal: enforce a narrower set of control-flow patterns when agents write or rewrite code. + +Initial rule idea: + +- disallow `if` +- disallow ternary expressions +- or allow separate switches so a project can ban just one of them first +- rewrite these forms to `switch` + +Why: + +- this is exactly the kind of mechanical rewrite an AI tool can do reliably +- a smaller allowed shape makes generated code more uniform +- `switch` is often the more idiomatic branch form in ReScript anyway + +Implementation notes: + +- this should likely be an AST-based rewrite rule +- it should support distinct config toggles for `if` and ternary if we want a softer rollout +- this can be intentionally aggressive, including rewriting `else if` chains into a canonical `switch` shape + +#### No `?Some(...)` + +Goal: disallow pointless option wrapping in optional argument positions. + +Examples to rewrite: + +- `~value=?Some(x)` +- `~value=?Some(expensiveComputation())` + +Why: + +- `?Some(...)` is redundant in these positions +- it adds noise without changing the meaning we want + +Implementation notes: + +- this should be an AST-based rewrite rule +- the rewrite is straightforward: `?Some(expr)` -> `?expr` + +#### Other good candidates + +- normalize boolean branching shape +- swap branches instead of preserving unnecessary `!cond` +- rewrite nested ternaries into structured `switch` +- normalize optional-argument spellings into one canonical form +- recursively normalize branches until they also match canonical syntax +- skip or reject rewrites in generated/PPX-shaped regions when preservation is unreliable + +## Lint Project Mode + +For `lint `: + +- discover project files through existing package/project loading machinery +- run per-file analysis +- aggregate findings + +For typed mode: + +- only analyze files with available `.cmt/.cmti` +- report missing semantic artifacts cleanly when needed + +## Lint Git Mode + +`--git` should narrow reporting to relevant changed code. + +### Phase 1 + +Filter findings to changed line ranges from the current diff. + +This is the cheapest and safest first version. + +### Phase 2 + +Expand changed lines to the enclosing code block, then filter findings to that block. + +Candidate block definitions: + +- top-level structure/signature item +- local `let` binding +- type declaration +- module declaration + +This is closer to how an AI edits code: a small change often affects the whole enclosing item. + +## Future + +This section is intentionally a scratchpad. Add ideas here freely before they are fully designed. + +### Compiler AI mode + +- AI mode for the compiler with output tailored to LLMs +- compact, structured diagnostics with stable field names +- error output shaped for repair loops instead of humans scanning terminal prose +- likely a separate mode/flag rather than changing normal compiler output + +### Deterministic codebase iteration + +- "Find all of type" + - example: find all local `external` definitions +- this should build on an iterator-style loop where an agent can traverse a codebase deterministically +- example flow: + - ask for all `external` + - get one item or one small page at a time + - keep explicit cursor/bookkeeping until the full result set is exhausted +- likely needs: + - stable ordering + - cursors or checkpoints + - optional notes/comments/bookkeeping attached by the agent + - resumability across multiple requests + +### CLI/agent-friendly semantic commands + +- find references for CLI/agents + - compact output + - easy pagination + - machine-readable ranges and symbol identity +- find definition / type definition +- hover-like symbol info as a CLI command + - type + - docs + - module path + - source location +- search symbols/modules by semantic identity rather than raw grep +- find implementations / exported values / local bindings by kind +- rename preview for agents + - show affected files and ranges before applying changes +- code-action preview for agents + - return suggested edits without editor integration +- range or block diagnostics + - ask for diagnostics only for one binding, type, module, or changed block + +### Other candidate directions + +- ad-hoc docs generation using the existing JSON doc extraction work +- more semantic rules driven by real AI editing failures +- diff-aware semantic review commands +- project summaries for agents + - exported modules + - notable types + - important entry points +- symbol-centric search + - "show me all modules exporting a type named `t`" + - "show all externals in this package" + - "show all uses of Belt in changed files" + +## Suggested Implementation Order + +1. Add `lint` command and help text in `tools/bin/main.ml` +2. Create a new `Tools.Lint` module in `tools/src/` +3. Define config parsing and finding/output types +4. Implement AST lane plumbing +5. Implement typed lane plumbing on top of `analysis/src/Cmt.ml` +6. Implement `forbidden-reference` +7. Implement local `single-use-function` +8. Add `--git` line-range filtering +9. Add tests for file mode and project mode +10. Add `rewrite` command and a separate `Tools.Rewrite` module +11. Implement rewrite verification and fixed-point pass ordering +12. Add aggressive canonicalization rules like `if`/ternary -> `switch` +13. Add optional-arg normalization like `?Some(expr)` -> `?expr` + +## Testing Plan + +We should cover at least: + +- source-only AST checks +- typed semantic checks using built project fixtures +- whole-project runs +- config-driven allow/deny cases +- `--git` filtering +- stable machine output snapshots + +Likely homes: + +- `tests/tools_tests/` for command behavior and output snapshots +- `tests/analysis_tests/` only if we need extra semantic fixtures + +## Open Questions + +1. Should default output be JSON, or should JSON be opt-in? +2. What should the config file be named? +3. Should typed mode fail hard when CMT is missing, or silently fall back in `auto` mode? +4. What exact forms count as a "single-use function" in V1? +5. In git mode, should we filter by raw changed lines only, or by enclosing AST block from day one? diff --git a/tests/tools_tests/.rescript-lint.json b/tests/tools_tests/.rescript-lint.json new file mode 100644 index 00000000000..dbb69adb7c5 --- /dev/null +++ b/tests/tools_tests/.rescript-lint.json @@ -0,0 +1,11 @@ +{ + "rules": { + "forbidden-reference": { + "severity": "error", + "items": ["Belt.Array.forEach", "Belt.Array.map", "Js.Json.t"] + }, + "single-use-function": { + "severity": "warning" + } + } +} diff --git a/tests/tools_tests/src/expected/Clean.res.lint.expected b/tests/tools_tests/src/expected/Clean.res.lint.expected new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/tools_tests/src/expected/Clean.res.lint.json.expected b/tests/tools_tests/src/expected/Clean.res.lint.json.expected new file mode 100644 index 00000000000..fe51488c706 --- /dev/null +++ b/tests/tools_tests/src/expected/Clean.res.lint.json.expected @@ -0,0 +1 @@ +[] diff --git a/tests/tools_tests/src/expected/ForbiddenExplicit.res.lint.expected b/tests/tools_tests/src/expected/ForbiddenExplicit.res.lint.expected new file mode 100644 index 00000000000..1dc6f31c7ec --- /dev/null +++ b/tests/tools_tests/src/expected/ForbiddenExplicit.res.lint.expected @@ -0,0 +1,11 @@ +severity: error +rule: forbidden-reference +path: src/lint/ForbiddenExplicit.res +range: 1:28-1:35 +message: Forbidden reference +symbol: Belt_Array.forEach +snippet: +```text +> 1 | let run = () => Belt.Array.forEach([1, 2], x => ignore(x)) + | ^^^^^^^ +``` diff --git a/tests/tools_tests/src/expected/ForbiddenExplicit.res.lint.json.expected b/tests/tools_tests/src/expected/ForbiddenExplicit.res.lint.json.expected new file mode 100644 index 00000000000..6eab87f9c8b --- /dev/null +++ b/tests/tools_tests/src/expected/ForbiddenExplicit.res.lint.json.expected @@ -0,0 +1 @@ +[{"rule":"forbidden-reference","path":"src/lint/ForbiddenExplicit.res","range":[0,27,0,34],"severity":"error","message":"Forbidden reference","symbol":"Belt_Array.forEach"}] diff --git a/tests/tools_tests/src/expected/ForbiddenExplicitNoCmt.res.lint.expected b/tests/tools_tests/src/expected/ForbiddenExplicitNoCmt.res.lint.expected new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/tools_tests/src/expected/ForbiddenExplicitNoCmt.res.lint.json.expected b/tests/tools_tests/src/expected/ForbiddenExplicitNoCmt.res.lint.json.expected new file mode 100644 index 00000000000..fe51488c706 --- /dev/null +++ b/tests/tools_tests/src/expected/ForbiddenExplicitNoCmt.res.lint.json.expected @@ -0,0 +1 @@ +[] diff --git a/tests/tools_tests/src/expected/ForbiddenOpen.res.lint.expected b/tests/tools_tests/src/expected/ForbiddenOpen.res.lint.expected new file mode 100644 index 00000000000..97ed9c66e4b --- /dev/null +++ b/tests/tools_tests/src/expected/ForbiddenOpen.res.lint.expected @@ -0,0 +1,12 @@ +severity: error +rule: forbidden-reference +path: src/lint/ForbiddenOpen.res +range: 3:17-3:20 +message: Forbidden reference +symbol: Belt_Array.map +snippet: +```text + 1 | open Belt.Array +> 3 | let run = () => map([1, 2], x => x + 1) + | ^^^ +``` diff --git a/tests/tools_tests/src/expected/ForbiddenOpen.res.lint.json.expected b/tests/tools_tests/src/expected/ForbiddenOpen.res.lint.json.expected new file mode 100644 index 00000000000..07bab16c0d2 --- /dev/null +++ b/tests/tools_tests/src/expected/ForbiddenOpen.res.lint.json.expected @@ -0,0 +1 @@ +[{"rule":"forbidden-reference","path":"src/lint/ForbiddenOpen.res","range":[2,16,2,19],"severity":"error","message":"Forbidden reference","symbol":"Belt_Array.map"}] diff --git a/tests/tools_tests/src/expected/ForbiddenType.res.lint.expected b/tests/tools_tests/src/expected/ForbiddenType.res.lint.expected new file mode 100644 index 00000000000..499c2a444b0 --- /dev/null +++ b/tests/tools_tests/src/expected/ForbiddenType.res.lint.expected @@ -0,0 +1,11 @@ +severity: error +rule: forbidden-reference +path: src/lint/ForbiddenType.res +range: 1:21-1:22 +message: Forbidden reference +symbol: Js_json.t +snippet: +```text +> 1 | type json = Js.Json.t + | ^ +``` diff --git a/tests/tools_tests/src/expected/ForbiddenType.res.lint.json.expected b/tests/tools_tests/src/expected/ForbiddenType.res.lint.json.expected new file mode 100644 index 00000000000..7b959a3ca37 --- /dev/null +++ b/tests/tools_tests/src/expected/ForbiddenType.res.lint.json.expected @@ -0,0 +1 @@ +[{"rule":"forbidden-reference","path":"src/lint/ForbiddenType.res","range":[0,20,0,21],"severity":"error","message":"Forbidden reference","symbol":"Js_json.t"}] diff --git a/tests/tools_tests/src/expected/SingleUse.res.lint.expected b/tests/tools_tests/src/expected/SingleUse.res.lint.expected new file mode 100644 index 00000000000..5ae4dfa113c --- /dev/null +++ b/tests/tools_tests/src/expected/SingleUse.res.lint.expected @@ -0,0 +1,13 @@ +severity: warning +rule: single-use-function +path: src/lint/SingleUse.res +range: 2:7-2:13 +message: Local function is only used once +symbol: helper +snippet: +```text + 1 | let run = () => { +> 2 | let helper = x => x + 1 + | ^^^^^^ + 3 | helper(1) +``` diff --git a/tests/tools_tests/src/expected/SingleUse.res.lint.json.expected b/tests/tools_tests/src/expected/SingleUse.res.lint.json.expected new file mode 100644 index 00000000000..450cc35ebda --- /dev/null +++ b/tests/tools_tests/src/expected/SingleUse.res.lint.json.expected @@ -0,0 +1 @@ +[{"rule":"single-use-function","path":"src/lint/SingleUse.res","range":[1,6,1,12],"severity":"warning","message":"Local function is only used once","symbol":"helper"}] diff --git a/tests/tools_tests/src/expected/lint-root.expected b/tests/tools_tests/src/expected/lint-root.expected new file mode 100644 index 00000000000..4365e7825a7 --- /dev/null +++ b/tests/tools_tests/src/expected/lint-root.expected @@ -0,0 +1,50 @@ +severity: error +rule: forbidden-reference +path: src/lint/ForbiddenExplicit.res +range: 1:28-1:35 +message: Forbidden reference +symbol: Belt_Array.forEach +snippet: +```text +> 1 | let run = () => Belt.Array.forEach([1, 2], x => ignore(x)) + | ^^^^^^^ +``` + +severity: error +rule: forbidden-reference +path: src/lint/ForbiddenOpen.res +range: 3:17-3:20 +message: Forbidden reference +symbol: Belt_Array.map +snippet: +```text + 1 | open Belt.Array +> 3 | let run = () => map([1, 2], x => x + 1) + | ^^^ +``` + +severity: error +rule: forbidden-reference +path: src/lint/ForbiddenType.res +range: 1:21-1:22 +message: Forbidden reference +symbol: Js_json.t +snippet: +```text +> 1 | type json = Js.Json.t + | ^ +``` + +severity: warning +rule: single-use-function +path: src/lint/SingleUse.res +range: 2:7-2:13 +message: Local function is only used once +symbol: helper +snippet: +```text + 1 | let run = () => { +> 2 | let helper = x => x + 1 + | ^^^^^^ + 3 | helper(1) +``` diff --git a/tests/tools_tests/src/expected/lint-root.json.expected b/tests/tools_tests/src/expected/lint-root.json.expected new file mode 100644 index 00000000000..7c4b25ff38a --- /dev/null +++ b/tests/tools_tests/src/expected/lint-root.json.expected @@ -0,0 +1 @@ +[{"rule":"forbidden-reference","path":"src/lint/ForbiddenExplicit.res","range":[0,27,0,34],"severity":"error","message":"Forbidden reference","symbol":"Belt_Array.forEach"},{"rule":"forbidden-reference","path":"src/lint/ForbiddenOpen.res","range":[2,16,2,19],"severity":"error","message":"Forbidden reference","symbol":"Belt_Array.map"},{"rule":"forbidden-reference","path":"src/lint/ForbiddenType.res","range":[0,20,0,21],"severity":"error","message":"Forbidden reference","symbol":"Js_json.t"},{"rule":"single-use-function","path":"src/lint/SingleUse.res","range":[1,6,1,12],"severity":"warning","message":"Local function is only used once","symbol":"helper"}] diff --git a/tests/tools_tests/src/lint/Clean.res b/tests/tools_tests/src/lint/Clean.res new file mode 100644 index 00000000000..3c37c33b59a --- /dev/null +++ b/tests/tools_tests/src/lint/Clean.res @@ -0,0 +1 @@ +let value = 1 diff --git a/tests/tools_tests/src/lint/ForbiddenExplicit.res b/tests/tools_tests/src/lint/ForbiddenExplicit.res new file mode 100644 index 00000000000..56ee51c7765 --- /dev/null +++ b/tests/tools_tests/src/lint/ForbiddenExplicit.res @@ -0,0 +1 @@ +let run = () => Belt.Array.forEach([1, 2], x => ignore(x)) diff --git a/tests/tools_tests/src/lint/ForbiddenOpen.res b/tests/tools_tests/src/lint/ForbiddenOpen.res new file mode 100644 index 00000000000..e9c51b67d1f --- /dev/null +++ b/tests/tools_tests/src/lint/ForbiddenOpen.res @@ -0,0 +1,3 @@ +open Belt.Array + +let run = () => map([1, 2], x => x + 1) diff --git a/tests/tools_tests/src/lint/ForbiddenType.res b/tests/tools_tests/src/lint/ForbiddenType.res new file mode 100644 index 00000000000..9c6dea3fa46 --- /dev/null +++ b/tests/tools_tests/src/lint/ForbiddenType.res @@ -0,0 +1 @@ +type json = Js.Json.t diff --git a/tests/tools_tests/src/lint/SingleUse.res b/tests/tools_tests/src/lint/SingleUse.res new file mode 100644 index 00000000000..d190c45f7bc --- /dev/null +++ b/tests/tools_tests/src/lint/SingleUse.res @@ -0,0 +1,4 @@ +let run = () => { + let helper = x => x + 1 + helper(1) +} diff --git a/tests/tools_tests/test.sh b/tests/tools_tests/test.sh index 4e44f421708..9a6af44da03 100755 --- a/tests/tools_tests/test.sh +++ b/tests/tools_tests/test.sh @@ -33,6 +33,44 @@ for file in src/docstrings-format/*.{res,resi,md}; do fi done +# Test lint command +for file in src/lint/*.res; do + output="src/expected/$(basename $file).lint.expected" + ../../_build/install/default/bin/rescript-tools lint "$file" > "$output" || true + if [ "$RUNNER_OS" == "Windows" ]; then + perl -pi -e 's/\r\n/\n/g' -- "$output" + fi + + json_output="src/expected/$(basename $file).lint.json.expected" + ../../_build/install/default/bin/rescript-tools lint "$file" --json > "$json_output" || true + if [ "$RUNNER_OS" == "Windows" ]; then + perl -pi -e 's/\r\n/\n/g' -- "$json_output" + fi +done + +unbuilt_file="unbuilt/ForbiddenExplicitNoCmt.res" +unbuilt_output="src/expected/$(basename $unbuilt_file).lint.expected" +../../_build/install/default/bin/rescript-tools lint "$unbuilt_file" > "$unbuilt_output" || true +if [ "$RUNNER_OS" == "Windows" ]; then + perl -pi -e 's/\r\n/\n/g' -- "$unbuilt_output" +fi + +unbuilt_json_output="src/expected/$(basename $unbuilt_file).lint.json.expected" +../../_build/install/default/bin/rescript-tools lint "$unbuilt_file" --json > "$unbuilt_json_output" || true +if [ "$RUNNER_OS" == "Windows" ]; then + perl -pi -e 's/\r\n/\n/g' -- "$unbuilt_json_output" +fi + +../../_build/install/default/bin/rescript-tools lint src/lint > src/expected/lint-root.expected || true +if [ "$RUNNER_OS" == "Windows" ]; then + perl -pi -e 's/\r\n/\n/g' -- src/expected/lint-root.expected +fi + +../../_build/install/default/bin/rescript-tools lint src/lint --json > src/expected/lint-root.json.expected || true +if [ "$RUNNER_OS" == "Windows" ]; then + perl -pi -e 's/\r\n/\n/g' -- src/expected/lint-root.json.expected +fi + # Test migrate command for file in src/migrate/*.{res,resi}; do output="src/expected/$(basename $file).expected" diff --git a/tests/tools_tests/unbuilt/ForbiddenExplicitNoCmt.res b/tests/tools_tests/unbuilt/ForbiddenExplicitNoCmt.res new file mode 100644 index 00000000000..56ee51c7765 --- /dev/null +++ b/tests/tools_tests/unbuilt/ForbiddenExplicitNoCmt.res @@ -0,0 +1 @@ +let run = () => Belt.Array.forEach([1, 2], x => ignore(x)) diff --git a/tools/bin/main.ml b/tools/bin/main.ml index 130ce24ac74..f8d42165ea8 100644 --- a/tools/bin/main.ml +++ b/tools/bin/main.ml @@ -25,6 +25,20 @@ Usage: rescript-tools extract-codeblocks [--transform-assert-equal] Example: rescript-tools extract-codeblocks ./path/to/MyModule.res|} +let lintHelp = + {|ReScript Tools + +Run AI-oriented lint checks on a file or directory + +Usage: rescript-tools lint [--config ] [--json] + +Notes: +- Text is the default output format +- Use --json for compact machine-readable JSON +- Both source AST and typed information are used when available + +Example: rescript-tools lint ./src/MyModule.res|} + let help = {|ReScript Tools @@ -40,6 +54,9 @@ format-codeblocks Format ReScript code blocks [--transform-assert-equal] Transform `assertEqual` to `==` extract-codeblocks Extract ReScript code blocks from file [--transform-assert-equal] Transform `==` to `assertEqual` +lint Run AI-oriented lint checks + [--config ] Use the given lint config file + [--json] Output compact JSON reanalyze Reanalyze reanalyze-server Start reanalyze server -v, --version Print version @@ -171,6 +188,27 @@ let main () = print_endline (Analysis.Protocol.stringifyResult r); exit 1) | _ -> logAndExit (Error extractCodeblocksHelp)) + | "lint" :: rest -> ( + match rest with + | ["-h"] | ["--help"] -> logAndExit (Ok lintHelp) + | path :: args -> ( + let rec parse_args config_path json = function + | [] -> Ok (config_path, json) + | "--json" :: rest -> parse_args config_path true rest + | "--config" :: config :: rest -> parse_args (Some config) json rest + | _ -> Error lintHelp + in + match parse_args None false args with + | Error help -> logAndExit (Error help) + | Ok (config_path, json) -> ( + match Tools.Lint.run ?config_path ~json path with + | Error err -> + prerr_endline err; + exit 2 + | Ok {Tools.Lint.output; has_findings} -> + if output <> "" then print_endline output; + exit (if has_findings then 1 else 0))) + | _ -> logAndExit (Error lintHelp)) | "reanalyze" :: _ -> if Sys.getenv_opt "RESCRIPT_REANALYZE_NO_SERVER" = Some "1" then ( let len = Array.length Sys.argv in diff --git a/tools/src/lint.ml b/tools/src/lint.ml new file mode 100644 index 00000000000..a8e12225485 --- /dev/null +++ b/tools/src/lint.ml @@ -0,0 +1,22 @@ +open Analysis + +type run_result = {output: string; has_findings: bool} + +let run ?config_path ?(json = false) target = + let target = + if Filename.is_relative target then Filename.concat (Sys.getcwd ()) target + else target + in + if not (Files.exists target) then + Error ("error: no such file or directory: " ^ target) + else + let target = Unix.realpath target in + Lint_config.load ?config_path target + |> Result.map (fun config -> + let {Lint_analysis.display_base; findings} = + Lint_analysis.analyze_target ~config target + in + { + output = Lint_output.render ~json ~display_base findings; + has_findings = findings <> []; + }) diff --git a/tools/src/lint_analysis.ml b/tools/src/lint_analysis.ml new file mode 100644 index 00000000000..07eefd01ce2 --- /dev/null +++ b/tools/src/lint_analysis.ml @@ -0,0 +1,460 @@ +open Analysis +open SharedTypes +open Lint_shared + +let starts_with_path ~prefix path = + let rec loop prefix path = + match (prefix, path) with + | [], _ -> true + | one :: prefix, two :: path when one = two -> loop prefix path + | _ -> false + in + loop prefix path + +let match_forbidden_path items path = + items + |> List.find_map (fun item -> + if starts_with_path ~prefix:item path then Some (item, path) else None) + +let path_key path = String.concat "." path + +let placeholder_module_path = "place holder" + +let package_for_path path = + let uri = Uri.fromPath path in + Packages.getPackage ~uri + +let declared_symbol_path ~module_name (declared : _ SharedTypes.Declared.t) = + module_name + :: SharedTypes.ModulePath.toPath declared.modulePath declared.name.txt + +let rec take_path count path = + if count <= 0 then [] + else + match path with + | [] -> [] + | head :: tail -> head :: take_path (count - 1) tail + +let rec drop_path count path = + if count <= 0 then path + else + match path with + | [] -> [] + | _ :: tail -> drop_path (count - 1) tail + +let resolve_module_env ~package path = + match path with + | [] -> None + | root_module :: nested_path -> ( + match ProcessCmt.fileForModule root_module ~package with + | None -> None + | Some file -> + let env = QueryEnv.fromFile file in + match nested_path with + | [] -> Some env + | _ -> + ResolvePath.resolvePath ~env + ~path:(nested_path @ [placeholder_module_path]) + ~package + |> Option.map fst) + +let resolve_exported_path ~env ~package path = + let resolve tip find_declared = + match References.exportedForTip ~env ~path ~package ~tip with + | None -> None + | Some (env, _name, stamp) -> + find_declared env.file.stamps stamp + |> Option.map (declared_symbol_path ~module_name:env.file.moduleName) + in + match path with + | [] -> Some [env.QueryEnv.file.moduleName] + | _ -> ( + match resolve Tip.Module Stamps.findModule with + | Some _ as resolved -> resolved + | None -> ( + match resolve Tip.Value Stamps.findValue with + | Some _ as resolved -> resolved + | None -> resolve Tip.Type Stamps.findType)) + +let resolve_forbidden_reference_items ~target_path items = + match package_for_path target_path with + | None -> items + | Some package -> + let resolved_module_cache = Hashtbl.create 16 in + let resolved_cache = Hashtbl.create 16 in + let resolve_module_prefix path = + match Hashtbl.find_opt resolved_module_cache (path_key path) with + | Some resolved -> resolved + | None -> + let resolved = resolve_module_env ~package path in + Hashtbl.add resolved_module_cache (path_key path) resolved; + resolved + in + let resolve_item_from_module_prefix item = + let rec loop prefix_length = + if prefix_length <= 0 then None + else + let module_prefix = take_path prefix_length item in + match resolve_module_prefix module_prefix with + | None -> loop (prefix_length - 1) + | Some env -> + let remainder = drop_path prefix_length item in + match remainder with + | [] -> Some [env.QueryEnv.file.moduleName] + | _ -> resolve_exported_path ~env ~package remainder + in + loop (List.length item) + in + let resolve_item item = + match Hashtbl.find_opt resolved_cache (path_key item) with + | Some resolved -> resolved + | None -> + let resolved = + resolve_item_from_module_prefix item + |> Option.value ~default:item + in + Hashtbl.add resolved_cache (path_key item) resolved; + resolved + in + items |> List.map resolve_item + +module Ast = struct + let rec is_function_expression (expression : Parsetree.expression) = + match expression.pexp_desc with + | Pexp_fun _ -> true + | Pexp_constraint (expression, _) + | Pexp_open (_, _, expression) + | Pexp_newtype (_, expression) -> + is_function_expression expression + | _ -> false + + let rec collect_binding_names (pattern : Parsetree.pattern) = + match pattern.ppat_desc with + | Ppat_var name | Ppat_alias (_, name) -> [name.loc] + | Ppat_constraint (pattern, _) -> collect_binding_names pattern + | _ -> [] + + let summary_of_file path = + let local_function_bindings = ref StringSet.empty in + let iterator = + let open Ast_iterator in + { + Ast_iterator.default_iterator with + expr = + (fun iter expression -> + (match expression.pexp_desc with + | Pexp_let (_rec_flag, bindings, _) -> + bindings + |> List.iter (fun (binding : Parsetree.value_binding) -> + if is_function_expression binding.pvb_expr then + collect_binding_names binding.pvb_pat + |> List.iter (fun loc -> + local_function_bindings := + StringSet.add (loc_key loc) + !local_function_bindings)) + | _ -> ()); + Ast_iterator.default_iterator.expr iter expression); + } + in + let parse_errors = + match Files.classifySourceFile path with + | Resi -> + let {Res_driver.parsetree; diagnostics; invalid; _} = + Res_driver.parsing_engine.parse_interface ~for_printer:false + ~filename:path + in + if not invalid then + Ast_iterator.default_iterator.signature iterator parsetree; + diagnostics + |> List.map (fun diagnostic -> + { + rule = "parse-error"; + abs_path = path; + loc = + { + Location.loc_start = + Res_diagnostics.get_start_pos diagnostic; + loc_end = Res_diagnostics.get_end_pos diagnostic; + loc_ghost = false; + }; + severity = SeverityError; + message = Res_diagnostics.explain diagnostic; + symbol = None; + }) + | _ -> + let {Res_driver.parsetree; diagnostics; invalid; _} = + Res_driver.parsing_engine.parse_implementation ~for_printer:false + ~filename:path + in + if not invalid then + Ast_iterator.default_iterator.structure iterator parsetree; + diagnostics + |> List.map (fun diagnostic -> + { + rule = "parse-error"; + abs_path = path; + loc = + { + Location.loc_start = + Res_diagnostics.get_start_pos diagnostic; + loc_end = Res_diagnostics.get_end_pos diagnostic; + loc_ghost = false; + }; + severity = SeverityError; + message = Res_diagnostics.explain diagnostic; + symbol = None; + }) + in + {parse_errors; local_function_bindings = !local_function_bindings} +end + +module Typed = struct + let resolve_global_symbol ~package ~module_name ~path ~tip = + match ProcessCmt.fileForModule module_name ~package with + | None -> None + | Some file -> + let env = QueryEnv.fromFile file in + Option.bind (References.exportedForTip ~env ~path ~package ~tip) + (fun (env, _name, stamp) -> + match tip with + | Tip.Value -> + Stamps.findValue env.file.stamps stamp + |> Option.map (fun declared -> + declared_symbol_path ~module_name:env.file.moduleName + declared) + | Tip.Type -> + Stamps.findType env.file.stamps stamp + |> Option.map (fun declared -> + declared_symbol_path ~module_name:env.file.moduleName + declared) + | Tip.Module -> + Stamps.findModule env.file.stamps stamp + |> Option.map (fun declared -> + declared_symbol_path ~module_name:env.file.moduleName + declared) + | Tip.Field field_name -> + Stamps.findType env.file.stamps stamp + |> Option.map (fun declared -> + declared_symbol_path ~module_name:env.file.moduleName + declared + @ [field_name]) + | Tip.Constructor constructor_name -> + Stamps.findType env.file.stamps stamp + |> Option.map (fun declared -> + declared_symbol_path ~module_name:env.file.moduleName + declared + @ [constructor_name])) + + let resolve_local_symbol ~(file : File.t) ~tip stamp = + match tip with + | Tip.Value -> + Stamps.findValue file.stamps stamp + |> Option.map (fun declared -> + declared_symbol_path ~module_name:file.moduleName declared) + | Tip.Type -> + Stamps.findType file.stamps stamp + |> Option.map (fun declared -> + declared_symbol_path ~module_name:file.moduleName declared) + | Tip.Module -> + Stamps.findModule file.stamps stamp + |> Option.map (fun declared -> + declared_symbol_path ~module_name:file.moduleName declared) + | Tip.Field field_name -> + Stamps.findType file.stamps stamp + |> Option.map (fun declared -> + declared_symbol_path ~module_name:file.moduleName declared + @ [field_name]) + | Tip.Constructor constructor_name -> + Stamps.findType file.stamps stamp + |> Option.map (fun declared -> + declared_symbol_path ~module_name:file.moduleName declared + @ [constructor_name]) + + let symbol_path (full : SharedTypes.full) (loc_item : locItem) = + match loc_item.locType with + | Typed (_, _typ, LocalReference (stamp, tip)) -> + resolve_local_symbol ~file:full.file ~tip stamp + | Typed (_, _typ, GlobalReference (module_name, path, tip)) -> + resolve_global_symbol ~package:full.package ~module_name ~path ~tip + | Typed (_, _, (Definition _ | NotFound)) + | LModule _ | TopLevelModule _ | Constant _ | TypeDefinition _ -> + None + + let forbidden_reference_findings ~config ~path (full : SharedTypes.full) = + if + (not config.forbidden_reference.enabled) + || config.forbidden_reference.items = [] + then [] + else + full.extra.locItems + |> List.filter_map (fun loc_item -> + match symbol_path full loc_item with + | None -> None + | Some symbol_path -> ( + match + match_forbidden_path config.forbidden_reference.items + symbol_path + with + | None -> None + | Some (_item, matched_path) -> + let symbol = Some (String.concat "." matched_path) in + Some + (raw_finding ~rule:"forbidden-reference" ~abs_path:path + ~loc:loc_item.loc + ~severity:config.forbidden_reference.severity + ~message:"Forbidden reference" ?symbol ()))) + + let is_function_type typ = + match (Shared.dig typ).desc with + | Tarrow _ -> true + | _ -> false + + let single_use_function_findings ~config ~path ~local_function_bindings + (full : SharedTypes.full) = + if not config.single_use_function.enabled then [] + else + let findings = ref [] in + Stamps.iterValues + (fun stamp (declared : Types.type_expr Declared.t) -> + if + (not declared.isExported) + && is_function_type declared.item + && StringSet.mem (loc_key declared.name.loc) local_function_bindings + then + let references = + Hashtbl.find_opt full.extra.internalReferences stamp + |> Option.value ~default:[] + in + let use_count = max 0 (List.length references - 1) in + if use_count = 1 then + let symbol = Some declared.name.txt in + findings := + raw_finding ~rule:"single-use-function" ~abs_path:path + ~loc:declared.name.loc + ~severity:config.single_use_function.severity + ~message:"Local function is only used once" ?symbol () + :: !findings) + full.file.stamps; + List.rev !findings +end + +let has_typed_artifact path = + let uri = Uri.fromPath path in + match Packages.getPackage ~uri with + | None -> false + | Some package -> + let module_name = + BuildSystem.namespacedName package.namespace (FindFiles.getName path) + in + Hashtbl.mem package.pathsForModule module_name + +let source_files_in_package package = + package.projectFiles |> FileSet.elements + |> List.filter_map (fun module_name -> + Hashtbl.find_opt package.pathsForModule module_name + |> Option.map getSrc) + |> List.concat + |> List.filter FindFiles.isSourceFile + +let collect_files target_path = + if Lint_support.Path.is_directory target_path then + match package_for_path target_path with + | Some package -> + source_files_in_package package + |> List.filter (fun file -> Files.pathStartsWith file target_path) + |> List.sort_uniq String.compare + | None -> + Files.collect target_path FindFiles.isSourceFile + |> List.sort_uniq String.compare + else [target_path] + +let display_base target_path files = + match files with + | file :: _ -> ( + match package_for_path file with + | Some package -> package.rootPath + | None -> + if Lint_support.Path.is_directory target_path then target_path + else Filename.dirname target_path) + | [] -> + if Lint_support.Path.is_directory target_path then target_path + else Filename.dirname target_path + +let dedupe_findings findings = + let same_signature left right = + left.rule = right.rule + && left.abs_path = right.abs_path + && left.symbol = right.symbol + && left.message = right.message + in + let rec loop acc = function + | [] -> List.rev acc + | finding :: rest -> + let skip = + acc + |> List.exists (fun seen -> + same_signature finding seen && Loc.isInside seen.loc finding.loc) + in + if skip then loop acc rest else loop (finding :: acc) rest + in + loop [] findings + +let compare_raw_findings left right = + let span ({Location.loc_start; loc_end} : Location.t) = + ( loc_end.pos_lnum - loc_start.pos_lnum, + loc_end.pos_cnum - loc_start.pos_cnum ) + in + compare + ( left.abs_path, + left.rule, + left.symbol, + left.message, + span left.loc, + Lint_support.Range.of_loc left.loc ) + ( right.abs_path, + right.rule, + right.symbol, + right.message, + span right.loc, + Lint_support.Range.of_loc right.loc ) + +let analyze_file ~config path = + let ast = Ast.summary_of_file path in + let findings = ref ast.parse_errors in + (if ast.parse_errors = [] then + match + if has_typed_artifact path then Cmt.loadFullCmtFromPath ~path else None + with + | None -> () + | Some full -> + findings := + Typed.forbidden_reference_findings ~config ~path full + @ Typed.single_use_function_findings ~config ~path + ~local_function_bindings:ast.local_function_bindings full + @ !findings); + !findings + +type analyzed_target = {display_base: string; findings: raw_finding list} + +let analyze_target ~config target_path = + let config = + { + config with + forbidden_reference = + { + config.forbidden_reference with + items = + resolve_forbidden_reference_items ~target_path + config.forbidden_reference.items; + }; + } + in + let files = collect_files target_path in + let display_base = display_base target_path files in + let findings = + files + |> List.concat_map (analyze_file ~config) + |> dedupe_findings + |> List.sort compare_raw_findings + in + {display_base; findings} diff --git a/tools/src/lint_config.ml b/tools/src/lint_config.ml new file mode 100644 index 00000000000..c6981aa6f69 --- /dev/null +++ b/tools/src/lint_config.ml @@ -0,0 +1,88 @@ +open Analysis +open Lint_shared + +let default_config = + { + forbidden_reference = {enabled = true; severity = SeverityError; items = []}; + single_use_function = {enabled = true; severity = SeverityWarning}; + } + +let parse_rule_severity ~default json = + match Option.bind (json |> Json.get "severity") Json.string with + | None -> default + | Some severity -> Option.value ~default (severity_of_string severity) + +let parse_config_json json = + let rules = + match json |> Json.get "rules" with + | Some (Json.Object _) as rules -> rules + | _ -> None + in + let get_rule name = + match rules with + | Some rules -> rules |> Json.get name + | None -> None + in + let forbidden_reference = + match get_rule "forbidden-reference" with + | Some rule -> + { + enabled = + Lint_support.Json.bool_with_default ~default:true rule "enabled"; + severity = + parse_rule_severity + ~default:default_config.forbidden_reference.severity rule; + items = + Lint_support.Json.string_array rule "items" + |> List.map (fun item -> + item |> String.split_on_char '.' + |> List.filter (fun segment -> segment <> "")); + } + | None -> default_config.forbidden_reference + in + let single_use_function = + match get_rule "single-use-function" with + | Some rule -> + { + enabled = + Lint_support.Json.bool_with_default ~default:true rule "enabled"; + severity = + parse_rule_severity + ~default:default_config.single_use_function.severity rule; + } + | None -> default_config.single_use_function + in + {forbidden_reference; single_use_function} + +let discover_config_path start_path = + let rec loop path = + let hidden = Filename.concat path ".rescript-lint.json" in + let visible = Filename.concat path "rescript-lint.json" in + if Files.exists hidden then Some hidden + else if Files.exists visible then Some visible + else + let parent = Filename.dirname path in + if parent = path then None else loop parent + in + loop start_path + +let load ?config_path target_path = + let config_path = + match config_path with + | Some path -> + let path = + if Filename.is_relative path then Filename.concat (Sys.getcwd ()) path + else path + in + Some path + | None -> + let start_path = + if Lint_support.Path.is_directory target_path then target_path + else Filename.dirname target_path + in + discover_config_path start_path + in + match config_path with + | None -> Ok default_config + | Some path -> + Lint_support.Json.read_file path |> Result.map parse_config_json diff --git a/tools/src/lint_output.ml b/tools/src/lint_output.ml new file mode 100644 index 00000000000..0781fc9277c --- /dev/null +++ b/tools/src/lint_output.ml @@ -0,0 +1,85 @@ +open Analysis +open Lint_shared + +let get_source source_cache path = + match Hashtbl.find_opt source_cache path with + | Some source -> source + | None -> + let source = Files.readFile path in + Hashtbl.add source_cache path source; + source + +let trim_trailing_newlines value = + let rec loop last = + if last < 0 then "" + else if value.[last] = '\n' || value.[last] = '\r' then loop (last - 1) + else String.sub value 0 (last + 1) + in + loop (String.length value - 1) + +let snippet_of_raw ~source_cache (raw_finding : raw_finding) = + match get_source source_cache raw_finding.abs_path with + | None -> None + | Some source -> + Code_frame.print ~highlight_style:Underlined ~context_lines_before:2 + ~context_lines_after:1 ~skip_blank_context:true + ~is_warning:(raw_finding.severity = SeverityWarning) + ~src:source ~start_pos:raw_finding.loc.loc_start + ~end_pos:raw_finding.loc.loc_end + |> trim_trailing_newlines + |> fun snippet -> Some ("```text\n" ^ snippet ^ "\n```") + +let compact_finding_fields (finding : finding) = + [ + ("rule", Some (Protocol.wrapInQuotes finding.rule)); + ("path", Some (Protocol.wrapInQuotes finding.path)); + ("range", Some (Lint_support.Range.to_compact_json finding.range)); + ( "severity", + Some (Protocol.wrapInQuotes (severity_to_string finding.severity)) ); + ("message", Some (Protocol.wrapInQuotes finding.message)); + ("symbol", Protocol.optWrapInQuotes finding.symbol); + ] + +let stringify_text_finding ~source_cache ~display_base + (raw_finding : raw_finding) = + let finding = finding_of_raw ~display_base raw_finding in + let lines = + [ + Printf.sprintf "severity: %s" (severity_to_string finding.severity); + Printf.sprintf "rule: %s" finding.rule; + Printf.sprintf "path: %s" finding.path; + Printf.sprintf "range: %s" (Lint_support.Range.to_text finding.range); + Printf.sprintf "message: %s" finding.message; + ] + in + let lines = + match finding.symbol with + | None -> lines + | Some symbol -> lines @ [Printf.sprintf "symbol: %s" symbol] + in + match snippet_of_raw ~source_cache raw_finding with + | None -> String.concat "\n" lines + | Some snippet -> String.concat "\n" (lines @ ["snippet:"; snippet]) + +let stringify_text_findings ~display_base findings = + let source_cache = Hashtbl.create 16 in + findings + |> List.map (stringify_text_finding ~source_cache ~display_base) + |> String.concat "\n\n" + +let stringify_compact_finding finding = + compact_finding_fields finding |> Lint_support.Json.stringify_compact_object + +let stringify_compact_findings ~display_base findings = + "[" + ^ String.concat "," + (findings + |> List.map (fun raw_finding -> + raw_finding + |> finding_of_raw ~display_base + |> stringify_compact_finding)) + ^ "]" + +let render ~json ~display_base findings = + if json then stringify_compact_findings ~display_base findings + else stringify_text_findings ~display_base findings diff --git a/tools/src/lint_shared.ml b/tools/src/lint_shared.ml new file mode 100644 index 00000000000..4566201c492 --- /dev/null +++ b/tools/src/lint_shared.ml @@ -0,0 +1,67 @@ +module StringSet = Set.Make (String) + +type severity = SeverityError | SeverityWarning + +type finding = { + rule: string; + path: string; + range: int * int * int * int; + severity: severity; + message: string; + symbol: string option; +} + +type raw_finding = { + rule: string; + abs_path: string; + loc: Location.t; + severity: severity; + message: string; + symbol: string option; +} + +type single_use_function_rule = {enabled: bool; severity: severity} + +type forbidden_reference_rule = { + enabled: bool; + severity: severity; + items: string list list; +} + +type config = { + forbidden_reference: forbidden_reference_rule; + single_use_function: single_use_function_rule; +} + +type ast_summary = { + parse_errors: raw_finding list; + local_function_bindings: StringSet.t; +} + +let severity_to_string = function + | SeverityError -> "error" + | SeverityWarning -> "warning" + +let severity_of_string = function + | "error" -> Some SeverityError + | "warning" -> Some SeverityWarning + | _ -> None + +let loc_key ({Location.loc_start; loc_end} : Location.t) = + Printf.sprintf "%d:%d:%d:%d" loc_start.pos_lnum + (loc_start.pos_cnum - loc_start.pos_bol) + loc_end.pos_lnum + (loc_end.pos_cnum - loc_end.pos_bol) + +let finding_of_raw ~display_base (finding : raw_finding) = + { + rule = finding.rule; + path = Lint_support.Path.display ~base:display_base finding.abs_path; + range = Lint_support.Range.of_loc finding.loc; + severity = finding.severity; + message = finding.message; + symbol = finding.symbol; + } + +let raw_finding ~rule ~abs_path ~loc ~severity ~message ?symbol () = + {rule; abs_path; loc; severity; message; symbol} diff --git a/tools/src/lint_support.ml b/tools/src/lint_support.ml new file mode 100644 index 00000000000..873fa89c055 --- /dev/null +++ b/tools/src/lint_support.ml @@ -0,0 +1,63 @@ +open Analysis + +module Analysis_json = Json + +module Json = struct + let read_file path = + match Files.readFile path with + | None -> Error ("error: unable to read " ^ path) + | Some raw -> ( + match Analysis_json.parse raw with + | None -> Error ("error: invalid json in " ^ path) + | Some json -> Ok json) + + let bool_with_default ~default json key = + Option.bind (json |> Analysis_json.get key) Analysis_json.bool + |> Option.value ~default + + let string_array json key = + match Option.bind (json |> Analysis_json.get key) Analysis_json.array with + | None -> [] + | Some items -> items |> List.filter_map Analysis_json.string + + let compact_field (key, value) = + match value with + | None -> None + | Some value -> Some (Printf.sprintf "\"%s\":%s" key value) + + let stringify_compact_object fields = + "{" ^ String.concat "," (fields |> List.filter_map compact_field) ^ "}" +end + +module Path = struct + let is_directory path = + match Unix.stat path with + | {Unix.st_kind = Unix.S_DIR} -> true + | _ -> false + | exception _ -> false + + let normalize_rel_path path = + path |> Files.split Filename.dir_sep |> String.concat "/" + + let display ~base path = + if base = "" then normalize_rel_path path + else normalize_rel_path (Files.relpath base path) +end + +module Range = struct + type t = int * int * int * int + + let of_loc (loc : Location.t) = + let range = Utils.cmtLocToRange loc in + ( range.start.line, + range.start.character, + range.end_.line, + range.end_.character ) + + let to_compact_json ((start_line, start_char, end_line, end_char) : t) = + Printf.sprintf "[%d,%d,%d,%d]" start_line start_char end_line end_char + + let to_text ((start_line, start_char, end_line, end_char) : t) = + Printf.sprintf "%d:%d-%d:%d" (start_line + 1) (start_char + 1) + (end_line + 1) (end_char + 1) +end diff --git a/tools/src/tools.ml b/tools/src/tools.ml index eb591aa9123..9de6bad17d9 100644 --- a/tools/src/tools.ml +++ b/tools/src/tools.ml @@ -1294,3 +1294,4 @@ module ExtractCodeblocks = struct end module Migrate = Migrate +module Lint = Lint From bf6f2f6ec93df3f2605b0e381328768a37ec73de Mon Sep 17 00:00:00 2001 From: Gabriel Nordeborn Date: Fri, 17 Apr 2026 17:35:06 +0200 Subject: [PATCH 02/17] add a pretty bad initial skills template --- docs/rescript_ai.md | 3 + docs/skills/rescript-ai-template/SKILL.md | 104 ++++++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 docs/skills/rescript-ai-template/SKILL.md diff --git a/docs/rescript_ai.md b/docs/rescript_ai.md index fa48ba99866..34f094ab392 100644 --- a/docs/rescript_ai.md +++ b/docs/rescript_ai.md @@ -4,6 +4,9 @@ This document captures a first working direction for AI-oriented tooling in ReSc The lint command is the first concrete piece, but the intended surface is broader: commands and output modes in `rescript-tools` that are shaped for LLMs and agent workflows. +A starter agent skill template for this workflow lives at +`docs/skills/rescript-ai-template/SKILL.md`. + ## Goal Build AI-oriented tooling that: diff --git a/docs/skills/rescript-ai-template/SKILL.md b/docs/skills/rescript-ai-template/SKILL.md new file mode 100644 index 00000000000..ee87f5c83d5 --- /dev/null +++ b/docs/skills/rescript-ai-template/SKILL.md @@ -0,0 +1,104 @@ +--- +name: rescript-ai-template +description: Template skill for agents working in a ReScript codebase with rescript-tools lint and related AI-oriented workflows. Use when creating a project-specific skill for file-by-file editing, lint-repair loops, and semantic checks. +--- + +# ReScript AI Template + +Use this as a starting point for a project-specific skill. Replace placeholders with repo-specific rules, commands, and ownership boundaries. + +## When To Use + +Use this skill when: + +- editing `.res` or `.resi` files +- fixing lint findings from `rescript-tools lint` +- doing small file-first repair loops +- checking semantic issues that depend on `.cmt/.cmti` + +## Default Workflow + +1. Prefer a file-first loop: + - edit one file + - run `rescript-tools lint ` + - fix findings + - rerun lint for that file + +2. Use root or subtree lint when: + - a change spans multiple modules + - resolving follow-on fallout + - preparing a wider verification pass + +3. Prefer text output by default. + +4. Use `--json` only when the agent needs machine-readable output for batching or post-processing. + +## Commands + +```sh +rescript-tools lint +rescript-tools lint +rescript-tools lint --json +``` + +Optional project verification commands: + +```sh +# Replace these with project-local commands +make test-syntax +make test-tools +make test +``` + +## How To Interpret Output + +- `severity`: `error` or `warning` +- `rule`: stable rule id +- `path`: repo-relative path +- `range`: 1-based text range in text mode +- `symbol`: resolved semantic symbol when available +- `snippet`: small local code fence for repair context + +Treat typed findings as the source of truth for semantic rules like forbidden references. + +## Editing Guidance + +- Keep fixes narrow. +- Prefer existing project helpers and patterns. +- Do not weaken lint rules just to silence findings. +- If a typed rule does not trigger, confirm `.cmt/.cmti` artifacts exist before assuming the code is clean. + +## Project Rules + +Fill this section in for the actual repo: + +- Forbidden modules or APIs: + - `` + - `` +- Preferred replacements: + - `` + - `` +- Rewrite or normalization preferences: + - `` + +## Repair Loop + +For one-file work: + +```sh +rescript-tools lint src/File.res +``` + +Fix the reported findings, then rerun the same command until clean. + +For broader fallout: + +```sh +rescript-tools lint src/ +``` + +## Notes + +- `lint` is for reporting. +- `rewrite` is a separate command and may be more aggressive once available. +- Git-aware filtering should be used when the repo enables that mode. From 5618da58c9b301273b8f0d3fcf60146b5462f523 Mon Sep 17 00:00:00 2001 From: Gabriel Nordeborn Date: Sun, 19 Apr 2026 15:14:42 +0200 Subject: [PATCH 03/17] add active-rules command --- .../src/expected/active-rules.expected | 38 ++++ .../src/expected/active-rules.json.expected | 1 + tests/tools_tests/test.sh | 11 ++ tools/bin/main.ml | 38 ++++ tools/src/active_rules.ml | 108 ++++++++++++ tools/src/lint_config.ml | 56 +++++- tools/src/lint_shared.ml | 166 ++++++++++++++++++ tools/src/tools.ml | 2 + 8 files changed, 417 insertions(+), 3 deletions(-) create mode 100644 tests/tools_tests/src/expected/active-rules.expected create mode 100644 tests/tools_tests/src/expected/active-rules.json.expected create mode 100644 tools/src/active_rules.ml diff --git a/tests/tools_tests/src/expected/active-rules.expected b/tests/tools_tests/src/expected/active-rules.expected new file mode 100644 index 00000000000..2d61bd4545b --- /dev/null +++ b/tests/tools_tests/src/expected/active-rules.expected @@ -0,0 +1,38 @@ +namespace: lint +rule: forbidden-reference +active: true +summary: Report references to configured modules, values, and types. +details: Uses typed references when available so opened modules and aliases resolve to the real symbol path before matching. +settings: +- enabled: true +- severity: error +- items: Belt.Array.forEach, Belt.Array.map, Js.Json.t + +namespace: lint +rule: single-use-function +active: true +summary: Report local non-exported functions that are defined once and used once. +details: Counts local function bindings and same-file typed references, then reports helpers that only have a single real use site. +settings: +- enabled: true +- severity: warning + +namespace: rewrite +rule: prefer-switch +active: true +summary: Rewrite `if` and ternary control flow into canonical `switch`. +details: Simple boolean branches become `switch condition`, and `else if` chains collapse into guarded `switch ()` cases. +settings: +- enabled: true +- if: true +- ternary: true + +namespace: rewrite +rule: no-optional-some +active: true +summary: Rewrite redundant optional-argument wrapping from `?Some(expr)` to the direct labeled form. +details: Turns `~label=?Some(expr)` into `~label=expr` when the argument is already in an optional position. +settings: +- enabled: true + +summary: 4 active, 0 inactive, 4 total diff --git a/tests/tools_tests/src/expected/active-rules.json.expected b/tests/tools_tests/src/expected/active-rules.json.expected new file mode 100644 index 00000000000..1943a082ab2 --- /dev/null +++ b/tests/tools_tests/src/expected/active-rules.json.expected @@ -0,0 +1 @@ +{"rules":[{"namespace":"lint","rule":"forbidden-reference","active":true,"summary":"Report references to configured modules, values, and types.","details":"Uses typed references when available so opened modules and aliases resolve to the real symbol path before matching.","settings":{"enabled":true,"severity":"error","items":["Belt.Array.forEach", "Belt.Array.map", "Js.Json.t"]}},{"namespace":"lint","rule":"single-use-function","active":true,"summary":"Report local non-exported functions that are defined once and used once.","details":"Counts local function bindings and same-file typed references, then reports helpers that only have a single real use site.","settings":{"enabled":true,"severity":"warning"}},{"namespace":"rewrite","rule":"prefer-switch","active":true,"summary":"Rewrite `if` and ternary control flow into canonical `switch`.","details":"Simple boolean branches become `switch condition`, and `else if` chains collapse into guarded `switch ()` cases.","settings":{"enabled":true,"if":true,"ternary":true}},{"namespace":"rewrite","rule":"no-optional-some","active":true,"summary":"Rewrite redundant optional-argument wrapping from `?Some(expr)` to the direct labeled form.","details":"Turns `~label=?Some(expr)` into `~label=expr` when the argument is already in an optional position.","settings":{"enabled":true}}],"summary":{"active":4,"inactive":0,"total":4}} diff --git a/tests/tools_tests/test.sh b/tests/tools_tests/test.sh index 9a6af44da03..76705926b1c 100755 --- a/tests/tools_tests/test.sh +++ b/tests/tools_tests/test.sh @@ -71,6 +71,17 @@ if [ "$RUNNER_OS" == "Windows" ]; then perl -pi -e 's/\r\n/\n/g' -- src/expected/lint-root.json.expected fi +# Test active-rules command +../../_build/install/default/bin/rescript-tools active-rules src/lint > src/expected/active-rules.expected || exit 1 +if [ "$RUNNER_OS" == "Windows" ]; then + perl -pi -e 's/\r\n/\n/g' -- src/expected/active-rules.expected +fi + +../../_build/install/default/bin/rescript-tools active-rules src/lint --json > src/expected/active-rules.json.expected || exit 1 +if [ "$RUNNER_OS" == "Windows" ]; then + perl -pi -e 's/\r\n/\n/g' -- src/expected/active-rules.json.expected +fi + # Test migrate command for file in src/migrate/*.{res,resi}; do output="src/expected/$(basename $file).expected" diff --git a/tools/bin/main.ml b/tools/bin/main.ml index f8d42165ea8..7ecf37ffe89 100644 --- a/tools/bin/main.ml +++ b/tools/bin/main.ml @@ -39,6 +39,20 @@ Notes: Example: rescript-tools lint ./src/MyModule.res|} +let activeRulesHelp = + {|ReScript Tools + +List lint and rewrite rules, whether they are currently active, and what they do + +Usage: rescript-tools active-rules [--config ] [--json] + +Notes: +- Uses the same config discovery path as lint and rewrite +- Includes both lint and rewrite rules +- Text is the default output format + +Example: rescript-tools active-rules ./src|} + let help = {|ReScript Tools @@ -57,6 +71,9 @@ extract-codeblocks Extract ReScript code blocks from file lint Run AI-oriented lint checks [--config ] Use the given lint config file [--json] Output compact JSON +active-rules List lint/rewrite rules and whether they are active + [--config ] Use the given lint config file + [--json] Output compact JSON reanalyze Reanalyze reanalyze-server Start reanalyze server -v, --version Print version @@ -209,6 +226,27 @@ let main () = if output <> "" then print_endline output; exit (if has_findings then 1 else 0))) | _ -> logAndExit (Error lintHelp)) + | "active-rules" :: rest -> ( + match rest with + | ["-h"] | ["--help"] -> logAndExit (Ok activeRulesHelp) + | path :: args -> ( + let rec parse_args config_path json = function + | [] -> Ok (config_path, json) + | "--json" :: rest -> parse_args config_path true rest + | "--config" :: config :: rest -> parse_args (Some config) json rest + | _ -> Error activeRulesHelp + in + match parse_args None false args with + | Error help -> logAndExit (Error help) + | Ok (config_path, json) -> ( + match Tools.ActiveRules.run ?config_path ~json path with + | Error err -> + prerr_endline err; + exit 2 + | Ok {Tools.ActiveRules.output} -> + if output <> "" then print_endline output; + exit 0)) + | _ -> logAndExit (Error activeRulesHelp)) | "reanalyze" :: _ -> if Sys.getenv_opt "RESCRIPT_REANALYZE_NO_SERVER" = Some "1" then ( let len = Array.length Sys.argv in diff --git a/tools/src/active_rules.ml b/tools/src/active_rules.ml new file mode 100644 index 00000000000..f863b638704 --- /dev/null +++ b/tools/src/active_rules.ml @@ -0,0 +1,108 @@ +open Analysis +open Lint_shared + +type run_result = {output: string} + +let stringify_setting_value_json = function + | RuleSettingBool value -> string_of_bool value + | RuleSettingString value -> Protocol.wrapInQuotes value + | RuleSettingStringList values -> + Protocol.array (values |> List.map Protocol.wrapInQuotes) + +let stringify_setting_json (key, value) = (key, Some (stringify_setting_value_json value)) + +let stringify_settings_json settings = + Lint_support.Json.stringify_compact_object + (settings |> List.map stringify_setting_json) + +let stringify_setting_text (key, value) = + Printf.sprintf "- %s: %s" key (rule_setting_value_to_text value) + +let stringify_rule_json (listed_rule : rule_listing) = + Lint_support.Json.stringify_compact_object + [ + ("namespace", Some (Protocol.wrapInQuotes listed_rule.namespace)); + ("rule", Some (Protocol.wrapInQuotes listed_rule.rule)); + ("active", Some (string_of_bool listed_rule.active)); + ("summary", Some (Protocol.wrapInQuotes listed_rule.summary)); + ("details", Some (Protocol.wrapInQuotes listed_rule.details)); + ("settings", Some (stringify_settings_json listed_rule.settings)); + ] + +let stringify_settings_text settings = + settings |> List.map stringify_setting_text + +let stringify_rule_text (listed_rule : rule_listing) = + let lines = + [ + Printf.sprintf "namespace: %s" listed_rule.namespace; + Printf.sprintf "rule: %s" listed_rule.rule; + Printf.sprintf "active: %s" (string_of_bool listed_rule.active); + Printf.sprintf "summary: %s" listed_rule.summary; + Printf.sprintf "details: %s" listed_rule.details; + ] + in + let lines = + if listed_rule.settings = [] then lines + else lines @ ("settings:" :: stringify_settings_text listed_rule.settings) + in + String.concat "\n" lines + +let render_summary_json listed_rules = + let total = List.length listed_rules in + let active = + listed_rules + |> List.fold_left + (fun count (listed_rule : rule_listing) -> + if listed_rule.active then count + 1 else count) + 0 + in + let inactive = total - active in + Lint_support.Json.stringify_compact_object + [ + ("active", Some (string_of_int active)); + ("inactive", Some (string_of_int inactive)); + ("total", Some (string_of_int total)); + ] + +let render_summary_text listed_rules = + let total = List.length listed_rules in + let active = + listed_rules + |> List.fold_left + (fun count (listed_rule : rule_listing) -> + if listed_rule.active then count + 1 else count) + 0 + in + let inactive = total - active in + Printf.sprintf "summary: %d active, %d inactive, %d total" active inactive total + +let render ~json listed_rules = + if json then + Lint_support.Json.stringify_compact_object + [ + ( "rules", + Some + ("[" + ^ String.concat "," + (listed_rules |> List.map stringify_rule_json) + ^ "]") ); + ("summary", Some (render_summary_json listed_rules)); + ] + else + String.concat "\n\n" + ((listed_rules |> List.map stringify_rule_text) + @ [render_summary_text listed_rules]) + +let run ?config_path ?(json = false) target = + let target = + if Filename.is_relative target then Filename.concat (Sys.getcwd ()) target + else target + in + if not (Files.exists target) then + Error ("error: no such file or directory: " ^ target) + else + let target = Unix.realpath target in + Lint_config.load ?config_path target + |> Result.map (fun config -> + {output = rule_listings_of_config config |> render ~json}) diff --git a/tools/src/lint_config.ml b/tools/src/lint_config.ml index c6981aa6f69..6bed242c359 100644 --- a/tools/src/lint_config.ml +++ b/tools/src/lint_config.ml @@ -5,6 +5,12 @@ let default_config = { forbidden_reference = {enabled = true; severity = SeverityError; items = []}; single_use_function = {enabled = true; severity = SeverityWarning}; + rewrite = + { + prefer_switch = + {enabled = true; rewrite_if = true; rewrite_ternary = true}; + no_optional_some = {enabled = true}; + }; } let parse_rule_severity ~default json = @@ -13,16 +19,31 @@ let parse_rule_severity ~default json = | Some severity -> Option.value ~default (severity_of_string severity) let parse_config_json json = + let lint_json = json |> Json.get "lint" in let rules = - match json |> Json.get "rules" with + match Option.bind lint_json (Json.get "rules") with | Some (Json.Object _) as rules -> rules - | _ -> None + | _ -> ( + match json |> Json.get "rules" with + | Some (Json.Object _) as rules -> rules + | _ -> None) in let get_rule name = match rules with | Some rules -> rules |> Json.get name | None -> None in + let rewrite_json = json |> Json.get "rewrite" in + let rewrite_rules = + match Option.bind rewrite_json (Json.get "rules") with + | Some (Json.Object _) as rules -> rules + | _ -> None + in + let get_rewrite_rule name = + match rewrite_rules with + | Some rules -> rules |> Json.get name + | None -> None + in let forbidden_reference = match get_rule "forbidden-reference" with | Some rule -> @@ -52,7 +73,36 @@ let parse_config_json json = } | None -> default_config.single_use_function in - {forbidden_reference; single_use_function} + let prefer_switch = + match get_rewrite_rule "prefer-switch" with + | Some rule -> + { + enabled = + Lint_support.Json.bool_with_default ~default:true rule "enabled"; + rewrite_if = + Lint_support.Json.bool_with_default + ~default:default_config.rewrite.prefer_switch.rewrite_if rule "if"; + rewrite_ternary = + Lint_support.Json.bool_with_default + ~default:default_config.rewrite.prefer_switch.rewrite_ternary rule + "ternary"; + } + | None -> default_config.rewrite.prefer_switch + in + let no_optional_some = + match get_rewrite_rule "no-optional-some" with + | Some rule -> + { + enabled = + Lint_support.Json.bool_with_default ~default:true rule "enabled"; + } + | None -> default_config.rewrite.no_optional_some + in + { + forbidden_reference; + single_use_function; + rewrite = {prefer_switch; no_optional_some}; + } let discover_config_path start_path = let rec loop path = diff --git a/tools/src/lint_shared.ml b/tools/src/lint_shared.ml index 4566201c492..2c8b4c0668b 100644 --- a/tools/src/lint_shared.ml +++ b/tools/src/lint_shared.ml @@ -28,11 +28,47 @@ type forbidden_reference_rule = { items: string list list; } +type prefer_switch_rule = { + enabled: bool; + rewrite_if: bool; + rewrite_ternary: bool; +} + +type no_optional_some_rule = {enabled: bool} + +type rewrite_config = { + prefer_switch: prefer_switch_rule; + no_optional_some: no_optional_some_rule; +} + type config = { forbidden_reference: forbidden_reference_rule; single_use_function: single_use_function_rule; + rewrite: rewrite_config; } +type rule_info = { + namespace: string; + rule: string; + summary: string; + details: string; + rewrite_note: string option; +} + +type rule_listing = { + namespace: string; + rule: string; + active: bool; + summary: string; + details: string; + settings: (string * rule_setting_value) list; +} + +and rule_setting_value = + | RuleSettingBool of bool + | RuleSettingString of string + | RuleSettingStringList of string list + type ast_summary = { parse_errors: raw_finding list; local_function_bindings: StringSet.t; @@ -42,6 +78,136 @@ let severity_to_string = function | SeverityError -> "error" | SeverityWarning -> "warning" +let rule_setting_value_to_text = function + | RuleSettingBool value -> string_of_bool value + | RuleSettingString value -> value + | RuleSettingStringList [] -> "(none)" + | RuleSettingStringList values -> String.concat ", " values + +let forbidden_reference_rule_info = + { + namespace = "lint"; + rule = "forbidden-reference"; + summary = "Report references to configured modules, values, and types."; + details = + "Uses typed references when available so opened modules and aliases \ + resolve to the real symbol path before matching."; + rewrite_note = None; + } + +let single_use_function_rule_info = + { + namespace = "lint"; + rule = "single-use-function"; + summary = + "Report local non-exported functions that are defined once and used once."; + details = + "Counts local function bindings and same-file typed references, then \ + reports helpers that only have a single real use site."; + rewrite_note = None; + } + +let prefer_switch_rule_info = + { + namespace = "rewrite"; + rule = "prefer-switch"; + summary = "Rewrite `if` and ternary control flow into canonical `switch`."; + details = + "Simple boolean branches become `switch condition`, and `else if` chains \ + collapse into guarded `switch ()` cases."; + rewrite_note = Some "rewrote `if` / ternary branches into `switch`"; + } + +let no_optional_some_rule_info = + { + namespace = "rewrite"; + rule = "no-optional-some"; + summary = + "Rewrite redundant optional-argument wrapping from `?Some(expr)` to the \ + direct labeled form."; + details = + "Turns `~label=?Some(expr)` into `~label=expr` when the argument is \ + already in an optional position."; + rewrite_note = Some "rewrote `~label=?Some(expr)` into `~label=expr`"; + } + +let configurable_rule_infos = + [ + forbidden_reference_rule_info; + single_use_function_rule_info; + prefer_switch_rule_info; + no_optional_some_rule_info; + ] + +let rewrite_rule_infos = + configurable_rule_infos + |> List.filter (fun (rule_info : rule_info) -> rule_info.namespace = "rewrite") + +let rewrite_note_for_rule rule = + rewrite_rule_infos + |> List.find_map (fun (rule_info : rule_info) -> + if rule_info.rule = rule then rule_info.rewrite_note else None) + |> Option.value ~default:"applied rewrite rule" + +let rule_listings_of_config (config : config) = + let item_paths = config.forbidden_reference.items |> List.map (String.concat ".") in + [ + { + namespace = forbidden_reference_rule_info.namespace; + rule = forbidden_reference_rule_info.rule; + active = + config.forbidden_reference.enabled && config.forbidden_reference.items <> []; + summary = forbidden_reference_rule_info.summary; + details = forbidden_reference_rule_info.details; + settings = + [ + ("enabled", RuleSettingBool config.forbidden_reference.enabled); + ( "severity", + RuleSettingString + (severity_to_string config.forbidden_reference.severity) ); + ("items", RuleSettingStringList item_paths); + ]; + }; + { + namespace = single_use_function_rule_info.namespace; + rule = single_use_function_rule_info.rule; + active = config.single_use_function.enabled; + summary = single_use_function_rule_info.summary; + details = single_use_function_rule_info.details; + settings = + [ + ("enabled", RuleSettingBool config.single_use_function.enabled); + ( "severity", + RuleSettingString + (severity_to_string config.single_use_function.severity) ); + ]; + }; + { + namespace = prefer_switch_rule_info.namespace; + rule = prefer_switch_rule_info.rule; + active = + config.rewrite.prefer_switch.enabled + && (config.rewrite.prefer_switch.rewrite_if + || config.rewrite.prefer_switch.rewrite_ternary); + summary = prefer_switch_rule_info.summary; + details = prefer_switch_rule_info.details; + settings = + [ + ("enabled", RuleSettingBool config.rewrite.prefer_switch.enabled); + ("if", RuleSettingBool config.rewrite.prefer_switch.rewrite_if); + ("ternary", RuleSettingBool config.rewrite.prefer_switch.rewrite_ternary); + ]; + }; + { + namespace = no_optional_some_rule_info.namespace; + rule = no_optional_some_rule_info.rule; + active = config.rewrite.no_optional_some.enabled; + summary = no_optional_some_rule_info.summary; + details = no_optional_some_rule_info.details; + settings = [("enabled", RuleSettingBool config.rewrite.no_optional_some.enabled)]; + }; + ] + let severity_of_string = function | "error" -> Some SeverityError | "warning" -> Some SeverityWarning diff --git a/tools/src/tools.ml b/tools/src/tools.ml index 9de6bad17d9..969060b17bd 100644 --- a/tools/src/tools.ml +++ b/tools/src/tools.ml @@ -1,5 +1,7 @@ open Analysis +module ActiveRules = Active_rules + module StringSet = Set.Make (String) type fieldDoc = { From f1e0396298e907858ac239dabac4da41e65390b7 Mon Sep 17 00:00:00 2001 From: Gabriel Nordeborn Date: Sun, 19 Apr 2026 15:20:02 +0200 Subject: [PATCH 04/17] add show command --- ...w-ShowFixture.Nested.makeGreeting.expected | 5 + ...e.Nested.makeGreeting.no-comments.expected | 3 + .../src/expected/show-ShowFixture.expected | 7 + .../expected/show-ShowFixture.item.expected | 3 + .../show-String.localeCompare.expected | 41 +++++ tests/tools_tests/src/show/ShowFixture.res | 12 ++ tests/tools_tests/test.sh | 28 +++ tools/bin/main.ml | 44 +++++ tools/src/lint_support.ml | 131 ++++++++++++++ tools/src/show.ml | 160 ++++++++++++++++++ tools/src/tools.ml | 1 + 11 files changed, 435 insertions(+) create mode 100644 tests/tools_tests/src/expected/show-ShowFixture.Nested.makeGreeting.expected create mode 100644 tests/tools_tests/src/expected/show-ShowFixture.Nested.makeGreeting.no-comments.expected create mode 100644 tests/tools_tests/src/expected/show-ShowFixture.expected create mode 100644 tests/tools_tests/src/expected/show-ShowFixture.item.expected create mode 100644 tests/tools_tests/src/expected/show-String.localeCompare.expected create mode 100644 tests/tools_tests/src/show/ShowFixture.res create mode 100644 tools/src/show.ml diff --git a/tests/tools_tests/src/expected/show-ShowFixture.Nested.makeGreeting.expected b/tests/tools_tests/src/expected/show-ShowFixture.Nested.makeGreeting.expected new file mode 100644 index 00000000000..2f5b139a3a0 --- /dev/null +++ b/tests/tools_tests/src/expected/show-ShowFixture.Nested.makeGreeting.expected @@ -0,0 +1,5 @@ +```rescript +string => string +``` +--- + Nested docs diff --git a/tests/tools_tests/src/expected/show-ShowFixture.Nested.makeGreeting.no-comments.expected b/tests/tools_tests/src/expected/show-ShowFixture.Nested.makeGreeting.no-comments.expected new file mode 100644 index 00000000000..6b224c97494 --- /dev/null +++ b/tests/tools_tests/src/expected/show-ShowFixture.Nested.makeGreeting.no-comments.expected @@ -0,0 +1,3 @@ +```rescript +string => string +``` diff --git a/tests/tools_tests/src/expected/show-ShowFixture.expected b/tests/tools_tests/src/expected/show-ShowFixture.expected new file mode 100644 index 00000000000..9f12cbf792e --- /dev/null +++ b/tests/tools_tests/src/expected/show-ShowFixture.expected @@ -0,0 +1,7 @@ +```rescript +module ShowFixture: { + type item = {label: string} + module Nested + let makeGreeting: string => string +} +``` diff --git a/tests/tools_tests/src/expected/show-ShowFixture.item.expected b/tests/tools_tests/src/expected/show-ShowFixture.item.expected new file mode 100644 index 00000000000..0243c6e4bf5 --- /dev/null +++ b/tests/tools_tests/src/expected/show-ShowFixture.item.expected @@ -0,0 +1,3 @@ +```rescript +type item = {label: string} +``` diff --git a/tests/tools_tests/src/expected/show-String.localeCompare.expected b/tests/tools_tests/src/expected/show-String.localeCompare.expected new file mode 100644 index 00000000000..a1e48856f1b --- /dev/null +++ b/tests/tools_tests/src/expected/show-String.localeCompare.expected @@ -0,0 +1,41 @@ +```rescript +( + string, + string, + ~locales: array=?, + ~options: Intl_Collator.options=?, +) => float +``` +--- + +`localeCompare(referenceStr, compareStr, ~locales=?, ~options=?)` returns a float indicating +whether a reference string comes before or after, or is the same as the given +string in sort order. Returns a negative value if `referenceStr` occurs before `compareStr`, +positive if `referenceStr` occurs after `compareStr`, `0` if they are equivalent. +Do not rely on exact return values of `-1` or `1`. + +Optionally takes `~locales` to specify locale(s) and `~options` to customize comparison behavior +(e.g., sensitivity, case ordering, numeric sorting). These correspond to the `Intl.Collator` options. +See [`String.localeCompare`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/localeCompare) on MDN. + +## Examples + +```rescript +String.localeCompare("a", "c") < 0.0 == true +String.localeCompare("a", "a") == 0.0 +String.localeCompare("a", "b", ~locales=["en-US"]) < 0.0 == true +String.localeCompare("a", "A", ~locales=["en-US"], ~options={sensitivity: #base}) == 0.0 +``` + + +--- +```rescript +type Stdlib_Intl_Collator.options = { + localeMatcher?: Intl_Common.localeMatcher, + usage?: usage, + sensitivity?: sensitivity, + ignorePunctuation?: bool, + numeric?: bool, + caseFirst?: caseFirst, +} +``` diff --git a/tests/tools_tests/src/show/ShowFixture.res b/tests/tools_tests/src/show/ShowFixture.res new file mode 100644 index 00000000000..0e7f3e2c641 --- /dev/null +++ b/tests/tools_tests/src/show/ShowFixture.res @@ -0,0 +1,12 @@ +/** Show fixture docs */ +type item = { + label: string, +} + +module Nested = { + /** Nested docs */ + let makeGreeting = (name: string) => "hello " ++ name +} + +/** Alias docs */ +let makeGreeting = Nested.makeGreeting diff --git a/tests/tools_tests/test.sh b/tests/tools_tests/test.sh index 76705926b1c..b3513dcbd2c 100755 --- a/tests/tools_tests/test.sh +++ b/tests/tools_tests/test.sh @@ -82,6 +82,34 @@ if [ "$RUNNER_OS" == "Windows" ]; then perl -pi -e 's/\r\n/\n/g' -- src/expected/active-rules.json.expected fi +# Test show command +../../_build/install/default/bin/rescript-tools show ShowFixture --kind module > src/expected/show-ShowFixture.expected || exit 1 +if [ "$RUNNER_OS" == "Windows" ]; then + perl -pi -e 's/\r\n/\n/g' -- src/expected/show-ShowFixture.expected +fi + +../../_build/install/default/bin/rescript-tools show ShowFixture.Nested.makeGreeting --kind value > src/expected/show-ShowFixture.Nested.makeGreeting.expected || exit 1 +if [ "$RUNNER_OS" == "Windows" ]; then + perl -pi -e 's/\r\n/\n/g' -- src/expected/show-ShowFixture.Nested.makeGreeting.expected +fi + +../../_build/install/default/bin/rescript-tools show ShowFixture.Nested.makeGreeting --kind value --comments omit > src/expected/show-ShowFixture.Nested.makeGreeting.no-comments.expected || exit 1 +if [ "$RUNNER_OS" == "Windows" ]; then + perl -pi -e 's/\r\n/\n/g' -- src/expected/show-ShowFixture.Nested.makeGreeting.no-comments.expected +fi + +../../_build/install/default/bin/rescript-tools show ShowFixture.item --kind type > src/expected/show-ShowFixture.item.expected || exit 1 +if [ "$RUNNER_OS" == "Windows" ]; then + perl -pi -e 's/\r\n/\n/g' -- src/expected/show-ShowFixture.item.expected +fi + +../../_build/install/default/bin/rescript-tools show String.localeCompare > src/expected/show-String.localeCompare.expected || exit 1 +if [ "$RUNNER_OS" == "Windows" ]; then + perl -pi -e 's/\r\n/\n/g' -- src/expected/show-String.localeCompare.expected +fi + +../../_build/install/default/bin/rescript-tools show String --kind module > /dev/null || exit 1 + # Test migrate command for file in src/migrate/*.{res,resi}; do output="src/expected/$(basename $file).expected" diff --git a/tools/bin/main.ml b/tools/bin/main.ml index 7ecf37ffe89..be9389be969 100644 --- a/tools/bin/main.ml +++ b/tools/bin/main.ml @@ -53,6 +53,21 @@ Notes: Example: rescript-tools active-rules ./src|} +let showHelp = + {|ReScript Tools + +Show hover-style semantic information for a module, value, or type path + +Usage: rescript-tools show [--kind ] [--context ] [--comments ] + +Notes: +- Symbol paths are user-facing paths like String or String.localeCompare +- Context defaults to the current working directory +- Kind defaults to auto +- Comments default to include + +Example: rescript-tools show String.localeCompare|} + let help = {|ReScript Tools @@ -247,6 +262,35 @@ let main () = if output <> "" then print_endline output; exit 0)) | _ -> logAndExit (Error activeRulesHelp)) + | "show" :: rest -> ( + match rest with + | ["-h"] | ["--help"] -> logAndExit (Ok showHelp) + | path :: args -> ( + let rec parse_args kind context_path comments_mode = function + | [] -> Ok (kind, context_path, comments_mode) + | "--kind" :: value :: rest -> ( + match Tools.Show.show_kind_of_string value with + | Some kind -> parse_args kind context_path comments_mode rest + | None -> Error showHelp) + | "--comments" :: value :: rest -> ( + match Tools.Show.comments_mode_of_string value with + | Some comments_mode -> parse_args kind context_path comments_mode rest + | None -> Error showHelp) + | "--context" :: path :: rest -> + parse_args kind (Some path) comments_mode rest + | _ -> Error showHelp + in + match parse_args Tools.Show.Auto None Tools.Show.Include args with + | Error help -> logAndExit (Error help) + | Ok (kind, context_path, comments_mode) -> ( + match Tools.Show.run ?context_path ~kind ~comments_mode path with + | Error err -> + prerr_endline err; + exit 2 + | Ok {Tools.Show.output} -> + if output <> "" then print_endline output; + exit 0)) + | _ -> logAndExit (Error showHelp)) | "reanalyze" :: _ -> if Sys.getenv_opt "RESCRIPT_REANALYZE_NO_SERVER" = Some "1" then ( let len = Array.length Sys.argv in diff --git a/tools/src/lint_support.ml b/tools/src/lint_support.ml index 873fa89c055..a8ab8284499 100644 --- a/tools/src/lint_support.ml +++ b/tools/src/lint_support.ml @@ -44,6 +44,137 @@ module Path = struct else normalize_rel_path (Files.relpath base path) end +module SymbolKind = struct + type t = Auto | Module | Value | Type + + let to_string = function + | Auto -> "auto" + | Module -> "module" + | Value -> "value" + | Type -> "type" + + let of_string = function + | "auto" -> Some Auto + | "module" -> Some Module + | "value" -> Some Value + | "type" -> Some Type + | _ -> None +end + +module SymbolPath = struct + type scope = { + env: SharedTypes.QueryEnv.t; + path: string list; + top_level_module: string option; + } + + let placeholder = "place holder" + + let split path = + path |> String.split_on_char '.' + |> List.filter (fun segment -> segment <> "") + + let strip_placeholder path = + match List.rev path with + | head :: rest when head = placeholder -> List.rev rest + | _ -> path + + let resolve_in_env ~env ~package path = + ResolvePath.resolvePath ~env ~package ~path:(path @ [placeholder]) + |> Option.map fst + + let resolve_open_env ~package open_path = + match strip_placeholder open_path with + | [] -> None + | root_module :: remainder -> ( + match ProcessCmt.fileForModule root_module ~package with + | None -> None + | Some file -> ( + match remainder with + | [] -> Some (SharedTypes.QueryEnv.fromFile file) + | _ -> + resolve_in_env ~env:(SharedTypes.QueryEnv.fromFile file) ~package + remainder)) + + let direct_scope ~package path = + match path with + | [] -> None + | root_module :: remainder -> ( + match ProcessCmt.fileForModule root_module ~package with + | None -> None + | Some file -> + Some + { + env = SharedTypes.QueryEnv.fromFile file; + path = remainder; + top_level_module = Some root_module; + }) + + let open_scopes ~package path = + package.SharedTypes.opens + |> List.filter_map (fun open_path -> + resolve_open_env ~package open_path + |> Option.map (fun env -> + {env; path; top_level_module = None})) + + let scopes ~package path = + match direct_scope ~package path with + | Some scope -> scope :: open_scopes ~package path + | None -> open_scopes ~package path + + let resolve_top_level_module ~package path = + match direct_scope ~package path with + | Some ({env; path = []; _} : scope) -> Some env.file + | Some _ | None -> None + + let resolve_exported ~package ~tip path = + scopes ~package path + |> List.find_map (fun ({env; path; _} : scope) -> + References.exportedForTip ~env ~path ~package ~tip + |> Option.map (fun (env, _name, stamp) -> (env, stamp))) + + let resolve_module_env ~package path = + scopes ~package path + |> List.find_map (fun ({env; path; top_level_module} : scope) -> + match (top_level_module, path) with + | Some _, [] -> Some env + | _ -> resolve_in_env ~env ~package path) +end + +module Snippet = struct + type cache = (string, string option) Hashtbl.t + + let create_cache () = Hashtbl.create 16 + + let get_source source_cache path = + match Hashtbl.find_opt source_cache path with + | Some source -> source + | None -> + let source = Files.readFile path in + Hashtbl.add source_cache path source; + source + + let trim_trailing_newlines value = + let rec loop last = + if last < 0 then "" + else if value.[last] = '\n' || value.[last] = '\r' then loop (last - 1) + else String.sub value 0 (last + 1) + in + loop (String.length value - 1) + + let of_loc ~source_cache ~path ~loc ?(context_lines_before = 2) + ?(context_lines_after = 1) ?(skip_blank_context = true) + ?(is_warning = false) () = + match get_source source_cache path with + | None -> None + | Some source -> + Code_frame.print ~highlight_style:Underlined ~context_lines_before + ~context_lines_after ~skip_blank_context ~is_warning ~src:source + ~start_pos:loc.Location.loc_start ~end_pos:loc.Location.loc_end + |> trim_trailing_newlines + |> fun snippet -> Some ("```text\n" ^ snippet ^ "\n```") +end + module Range = struct type t = int * int * int * int diff --git a/tools/src/show.ml b/tools/src/show.ml new file mode 100644 index 00000000000..d863a656794 --- /dev/null +++ b/tools/src/show.ml @@ -0,0 +1,160 @@ +open Analysis +open SharedTypes + +type show_kind = Lint_support.SymbolKind.t = + | Auto + | Module + | Value + | Type + +type comments_mode = Include | Omit + +type run_result = {output: string} + +let show_kind_to_string = Lint_support.SymbolKind.to_string +let show_kind_of_string = Lint_support.SymbolKind.of_string + +let comments_mode_of_string = function + | "include" -> Some Include + | "omit" -> Some Omit + | _ -> None + +let normalize_path path = + if Filename.is_relative path then Filename.concat (Sys.getcwd ()) path + else path + +let package_for_context path = + let uri = Uri.fromPath path in + Packages.getPackage ~uri + +let display_name path = + match List.rev path with + | name :: _ -> name + | [] -> "" + +let trim_trailing_horizontal_whitespace value = + let rec loop last = + if last < 0 then "" + else + match value.[last] with + | ' ' | '\t' -> loop (last - 1) + | _ -> String.sub value 0 (last + 1) + in + loop (String.length value - 1) + +let normalize_output output = + let rec drop_trailing_blank_lines_rev = function + | "" :: rest -> drop_trailing_blank_lines_rev rest + | lines -> lines + in + output |> String.split_on_char '\n' + |> List.map trim_trailing_horizontal_whitespace |> List.rev + |> drop_trailing_blank_lines_rev |> List.rev |> String.concat "\n" + +let docstring_for_mode ~comments_mode docstring = + match comments_mode with + | Include -> docstring + | Omit -> [] + +let resolve_module_hover ~package ~comments_mode path = + let name = display_name path in + match Lint_support.SymbolPath.resolve_top_level_module ~package path with + | Some file -> + Hover.showModule + ~docstring:(docstring_for_mode ~comments_mode file.structure.docstring) + ~name ~file ~package None + | None -> ( + match Lint_support.SymbolPath.resolve_exported ~package ~tip:Tip.Module path with + | None -> None + | Some (env, stamp) -> ( + match Stamps.findModule env.file.stamps stamp with + | None -> None + | Some declared -> + Hover.showModule + ~docstring:(docstring_for_mode ~comments_mode declared.docstring) + ~name ~file:env.file ~package (Some declared))) + +let resolve_value_hover ~package ~comments_mode path = + match Lint_support.SymbolPath.resolve_exported ~package ~tip:Tip.Value path with + | None -> None + | Some (env, stamp) -> ( + match Stamps.findValue env.file.stamps stamp with + | None -> None + | Some declared -> + Some + (Hover.hoverWithExpandedTypes ~file:env.file ~package + ~supportsMarkdownLinks:false + ~docstring: + (docstring_for_mode ~comments_mode declared.docstring) + declared.item)) + +let resolve_type_hover ~package path = + match Lint_support.SymbolPath.resolve_exported ~package ~tip:Tip.Type path with + | None -> None + | Some (env, stamp) -> ( + match Stamps.findType env.file.stamps stamp with + | None -> None + | Some declared -> + let type_definition = + Markdown.codeBlock (Shared.declToString declared.name.txt declared.item.decl) + in + match declared.item.decl.type_manifest with + | None -> Some type_definition + | Some typ -> ( + let expanded_types, expansion_type = + Hover.expandTypes ~file:env.file ~package ~supportsMarkdownLinks:false + typ + in + match expansion_type with + | `Default -> Some (String.concat "\n" (type_definition :: expanded_types)) + | `InlineType -> Some (String.concat "\n" expanded_types))) + +let try_resolvers resolvers = + resolvers + |> List.find_map (fun (kind, resolver) -> + resolver () |> Option.map (fun output -> (kind, output))) + +let resolve_hover ~package ~kind ~comments_mode path = + match kind with + | Auto -> + try_resolvers + [ + (Module, fun () -> resolve_module_hover ~package ~comments_mode path); + (Value, fun () -> resolve_value_hover ~package ~comments_mode path); + (Type, fun () -> resolve_type_hover ~package path); + ] + | Module -> + resolve_module_hover ~package ~comments_mode path + |> Option.map (fun output -> (Module, output)) + | Value -> + resolve_value_hover ~package ~comments_mode path + |> Option.map (fun output -> (Value, output)) + | Type -> + resolve_type_hover ~package path + |> Option.map (fun output -> (Type, output)) + +let run ?context_path ?(kind = Auto) ?(comments_mode = Include) path = + let context_path = + match context_path with + | Some path -> normalize_path path + | None -> Sys.getcwd () + in + if not (Files.exists context_path) then + Error ("error: no such file or directory: " ^ context_path) + else + let path = Lint_support.SymbolPath.split path in + match path with + | [] -> Error "error: expected a symbol path like String.localeCompare" + | _ -> ( + match package_for_context context_path with + | None -> + Error + ("error: failed to load ReScript project context from " ^ context_path) + | Some package -> ( + match resolve_hover ~package ~kind ~comments_mode path with + | Some (_resolved_kind, output) -> Ok {output = normalize_output output} + | None -> + Error + (Printf.sprintf "error: could not resolve %s as %s" + (String.concat "." path) + (show_kind_to_string kind)))) diff --git a/tools/src/tools.ml b/tools/src/tools.ml index 969060b17bd..350dd92a75a 100644 --- a/tools/src/tools.ml +++ b/tools/src/tools.ml @@ -1297,3 +1297,4 @@ end module Migrate = Migrate module Lint = Lint +module Show = Show From 9edf95ad0a0b1f200f0bb497b5c0a71273c479fb Mon Sep 17 00:00:00 2001 From: Gabriel Nordeborn Date: Sun, 19 Apr 2026 15:22:28 +0200 Subject: [PATCH 05/17] add find-references command --- ...indReferencesFixture.makeGreeting.expected | 33 +++ .../find-references-location.expected | 33 +++ .../find_references/FindReferencesFixture.res | 10 + .../src/find_references/FindReferencesUse.res | 4 + tests/tools_tests/test.sh | 13 + tools/bin/main.ml | 73 +++++ tools/src/find_references.ml | 275 ++++++++++++++++++ tools/src/tools.ml | 1 + 8 files changed, 442 insertions(+) create mode 100644 tests/tools_tests/src/expected/find-references-FindReferencesFixture.makeGreeting.expected create mode 100644 tests/tools_tests/src/expected/find-references-location.expected create mode 100644 tests/tools_tests/src/find_references/FindReferencesFixture.res create mode 100644 tests/tools_tests/src/find_references/FindReferencesUse.res create mode 100644 tools/src/find_references.ml diff --git a/tests/tools_tests/src/expected/find-references-FindReferencesFixture.makeGreeting.expected b/tests/tools_tests/src/expected/find-references-FindReferencesFixture.makeGreeting.expected new file mode 100644 index 00000000000..967c13ad9f0 --- /dev/null +++ b/tests/tools_tests/src/expected/find-references-FindReferencesFixture.makeGreeting.expected @@ -0,0 +1,33 @@ +mode: symbol +symbol: FindReferencesFixture.makeGreeting +kind: value +count: 3 + +path: src/find_references/FindReferencesFixture.res +range: 9:5-9:17 +snippet: +```text + 7 | } +> 9 | let makeGreeting = Nested.makeGreeting + | ^^^^^^^^^^^^ + 10 | let itemToString = (item: item) => item.label +``` + +path: src/find_references/FindReferencesUse.res +range: 1:33-1:45 +snippet: +```text +> 1 | let one = FindReferencesFixture.makeGreeting("one") + | ^^^^^^^^^^^^ + 2 | let two = FindReferencesFixture.Nested.makeGreeting("two") +``` + +path: src/find_references/FindReferencesUse.res +range: 4:35-4:47 +snippet: +```text + 2 | let two = FindReferencesFixture.Nested.makeGreeting("two") + 3 | type alias = FindReferencesFixture.item +> 4 | let three = FindReferencesFixture.makeGreeting("three") + | ^^^^^^^^^^^^ +``` diff --git a/tests/tools_tests/src/expected/find-references-location.expected b/tests/tools_tests/src/expected/find-references-location.expected new file mode 100644 index 00000000000..febcbd29193 --- /dev/null +++ b/tests/tools_tests/src/expected/find-references-location.expected @@ -0,0 +1,33 @@ +mode: location +path: src/find_references/FindReferencesUse.res +position: 4:35 +count: 3 + +path: src/find_references/FindReferencesFixture.res +range: 9:5-9:17 +snippet: +```text + 7 | } +> 9 | let makeGreeting = Nested.makeGreeting + | ^^^^^^^^^^^^ + 10 | let itemToString = (item: item) => item.label +``` + +path: src/find_references/FindReferencesUse.res +range: 1:33-1:45 +snippet: +```text +> 1 | let one = FindReferencesFixture.makeGreeting("one") + | ^^^^^^^^^^^^ + 2 | let two = FindReferencesFixture.Nested.makeGreeting("two") +``` + +path: src/find_references/FindReferencesUse.res +range: 4:35-4:47 +snippet: +```text + 2 | let two = FindReferencesFixture.Nested.makeGreeting("two") + 3 | type alias = FindReferencesFixture.item +> 4 | let three = FindReferencesFixture.makeGreeting("three") + | ^^^^^^^^^^^^ +``` diff --git a/tests/tools_tests/src/find_references/FindReferencesFixture.res b/tests/tools_tests/src/find_references/FindReferencesFixture.res new file mode 100644 index 00000000000..514d58513fe --- /dev/null +++ b/tests/tools_tests/src/find_references/FindReferencesFixture.res @@ -0,0 +1,10 @@ +type item = { + label: string, +} + +module Nested = { + let makeGreeting = (name: string) => "hello " ++ name +} + +let makeGreeting = Nested.makeGreeting +let itemToString = (item: item) => item.label diff --git a/tests/tools_tests/src/find_references/FindReferencesUse.res b/tests/tools_tests/src/find_references/FindReferencesUse.res new file mode 100644 index 00000000000..2f48764d982 --- /dev/null +++ b/tests/tools_tests/src/find_references/FindReferencesUse.res @@ -0,0 +1,4 @@ +let one = FindReferencesFixture.makeGreeting("one") +let two = FindReferencesFixture.Nested.makeGreeting("two") +type alias = FindReferencesFixture.item +let three = FindReferencesFixture.makeGreeting("three") diff --git a/tests/tools_tests/test.sh b/tests/tools_tests/test.sh index b3513dcbd2c..355418e03da 100755 --- a/tests/tools_tests/test.sh +++ b/tests/tools_tests/test.sh @@ -110,6 +110,19 @@ fi ../../_build/install/default/bin/rescript-tools show String --kind module > /dev/null || exit 1 + +# Test find-references command +../../_build/install/default/bin/rescript-tools find-references FindReferencesFixture.makeGreeting --kind value > src/expected/find-references-FindReferencesFixture.makeGreeting.expected || exit 1 +if [ "$RUNNER_OS" == "Windows" ]; then + perl -pi -e 's/\r\n/\n/g' -- src/expected/find-references-FindReferencesFixture.makeGreeting.expected +fi + +../../_build/install/default/bin/rescript-tools find-references --file src/find_references/FindReferencesUse.res --line 4 --col 35 > src/expected/find-references-location.expected || exit 1 +if [ "$RUNNER_OS" == "Windows" ]; then + perl -pi -e 's/\r\n/\n/g' -- src/expected/find-references-location.expected +fi + +../../_build/install/default/bin/rescript-tools find-references FindReferencesFixture --kind module > /dev/null || exit 1 # Test migrate command for file in src/migrate/*.{res,resi}; do output="src/expected/$(basename $file).expected" diff --git a/tools/bin/main.ml b/tools/bin/main.ml index be9389be969..59b1f234d89 100644 --- a/tools/bin/main.ml +++ b/tools/bin/main.ml @@ -68,6 +68,22 @@ Notes: Example: rescript-tools show String.localeCompare|} +let findReferencesHelp = + {|ReScript Tools + +Find references for a symbol path or for the symbol at a source location + +Usage: + rescript-tools find-references [--kind ] [--context ] + rescript-tools find-references --file --line --col + +Notes: +- Positional input means symbol-path mode +- Location mode uses 1-based line and column numbers +- Context defaults to the current working directory in symbol-path mode + +Example: rescript-tools find-references String.localeCompare|} + let help = {|ReScript Tools @@ -291,6 +307,63 @@ let main () = if output <> "" then print_endline output; exit 0)) | _ -> logAndExit (Error showHelp)) + | "find-references" :: rest -> ( + match rest with + | ["-h"] | ["--help"] -> logAndExit (Ok findReferencesHelp) + | args -> + let rec parse_args symbol_path kind context_path file_path line col = + function + | [] -> Ok (symbol_path, kind, context_path, file_path, line, col) + | "--kind" :: value :: rest -> ( + match Tools.Find_references.symbol_kind_of_string value with + | Some kind -> + parse_args symbol_path kind context_path file_path line col rest + | None -> Error findReferencesHelp) + | "--context" :: path :: rest -> + parse_args symbol_path kind (Some path) file_path line col rest + | "--file" :: path :: rest -> + parse_args symbol_path kind context_path (Some path) line col rest + | "--line" :: value :: rest -> ( + match int_of_string_opt value with + | Some line -> + parse_args symbol_path kind context_path file_path (Some line) col + rest + | None -> Error findReferencesHelp) + | "--col" :: value :: rest -> ( + match int_of_string_opt value with + | Some col -> + parse_args symbol_path kind context_path file_path line (Some col) + rest + | None -> Error findReferencesHelp) + | value :: rest when not (String.starts_with ~prefix:"--" value) -> ( + match symbol_path with + | None -> + parse_args (Some value) kind context_path file_path line col rest + | Some _ -> Error findReferencesHelp) + | _ -> Error findReferencesHelp + in + match parse_args None Tools.Find_references.Auto None None None None args with + | Error help -> logAndExit (Error help) + | Ok (symbol_path, kind, context_path, file_path, line, col) -> ( + let query = + match (symbol_path, file_path, line, col) with + | Some symbol_path, None, None, None -> + Some + (Tools.Find_references.Symbol {symbol_path; kind; context_path}) + | None, Some file_path, Some line, Some col -> + Some (Tools.Find_references.Location {file_path; line; col}) + | _ -> None + in + match query with + | None -> logAndExit (Error findReferencesHelp) + | Some query -> ( + match Tools.Find_references.run query with + | Error err -> + prerr_endline err; + exit 2 + | Ok {Tools.Find_references.output; _} -> + if output <> "" then print_endline output; + exit 0))) | "reanalyze" :: _ -> if Sys.getenv_opt "RESCRIPT_REANALYZE_NO_SERVER" = Some "1" then ( let len = Array.length Sys.argv in diff --git a/tools/src/find_references.ml b/tools/src/find_references.ml new file mode 100644 index 00000000000..9459c31b9b1 --- /dev/null +++ b/tools/src/find_references.ml @@ -0,0 +1,275 @@ +open Analysis +open SharedTypes + +type symbol_kind = Lint_support.SymbolKind.t = + | Auto + | Module + | Value + | Type + +type query = + | Symbol of { + symbol_path: string; + kind: symbol_kind; + context_path: string option; + } + | Location of {file_path: string; line: int; col: int} + +type raw_reference = {abs_path: string; loc: Location.t option} + +type query_info = + | SymbolQuery of {symbol_path: string; resolved_kind: symbol_kind} + | LocationQuery of {abs_path: string; line: int; col: int} + +type run_result = {output: string; count: int} + +let symbol_kind_of_string = Lint_support.SymbolKind.of_string + +let normalize_path path = + if Filename.is_relative path then Filename.concat (Sys.getcwd ()) path + else path + +let package_for_path path = + let uri = Uri.fromPath path in + Packages.getPackage ~uri + +let display_base_for_path path = + match package_for_path path with + | Some package -> package.rootPath + | None -> + if Lint_support.Path.is_directory path then path else Filename.dirname path + +let raw_reference_of_analysis_reference ({References.uri; locOpt} : + References.references) = + {abs_path = Uri.toPath uri; loc = locOpt} + +let compare_loc_opt left right = + match (left, right) with + | None, None -> 0 + | None, Some _ -> -1 + | Some _, None -> 1 + | Some left, Some right -> + compare (Lint_support.Range.of_loc left) (Lint_support.Range.of_loc right) + +let compare_raw_reference left right = + compare (left.abs_path, left.loc |> Option.map Lint_support.Range.of_loc) + (right.abs_path, right.loc |> Option.map Lint_support.Range.of_loc) + +let sort_and_dedupe references = + let same_reference left right = + left.abs_path = right.abs_path && compare_loc_opt left.loc right.loc = 0 + in + let rec loop acc = function + | [] -> List.rev acc + | reference :: rest -> ( + match acc with + | seen :: _ when same_reference reference seen -> loop acc rest + | _ -> loop (reference :: acc) rest) + in + references |> List.sort compare_raw_reference |> loop [] + +let references_from_loc_item full loc_item = + References.allReferencesForLocItem ~full loc_item + |> List.map raw_reference_of_analysis_reference + |> sort_and_dedupe + +let references_for_top_level_module (file : File.t) = + match Cmt.fullFromUri ~uri:file.uri with + | None -> + Error + (Printf.sprintf "error: failed to load typed info for %s" + (Uri.toPath file.uri)) + | Some full -> + Ok + (references_from_loc_item full + {loc = Location.none; locType = TopLevelModule file.moduleName}) + +let references_for_exported ~(env : QueryEnv.t) ~tip ~stamp = + match Cmt.fullFromUri ~uri:env.file.uri with + | None -> + Error + (Printf.sprintf "error: failed to load typed info for %s" + (Uri.toPath env.file.uri)) + | Some full -> + Ok + (References.forLocalStamp ~full stamp tip + |> List.map raw_reference_of_analysis_reference + |> sort_and_dedupe) + +let resolve_module_references ~package path = + match Lint_support.SymbolPath.resolve_top_level_module ~package path with + | Some file -> + Some + (references_for_top_level_module file + |> Result.map (fun refs -> (Module, refs))) + | None -> ( + match + Lint_support.SymbolPath.resolve_exported ~package ~tip:Tip.Module path + with + | None -> None + | Some (env, stamp) -> + Some + (references_for_exported ~env ~tip:Tip.Module ~stamp + |> Result.map (fun refs -> (Module, refs)))) + +let resolve_value_references ~package path = + match + Lint_support.SymbolPath.resolve_exported ~package ~tip:Tip.Value path + with + | None -> None + | Some (env, stamp) -> + Some + (references_for_exported ~env ~tip:Tip.Value ~stamp + |> Result.map (fun refs -> (Value, refs))) + +let resolve_type_references ~package path = + match Lint_support.SymbolPath.resolve_exported ~package ~tip:Tip.Type path with + | None -> None + | Some (env, stamp) -> + Some + (references_for_exported ~env ~tip:Tip.Type ~stamp + |> Result.map (fun refs -> (Type, refs))) + +let rec try_symbol_resolvers = function + | [] -> Ok None + | resolver :: rest -> ( + match resolver () with + | None -> try_symbol_resolvers rest + | Some (Ok resolved) -> Ok (Some resolved) + | Some (Error _ as error) -> error |> Result.map Option.some) + +let resolve_symbol_references ~package ~kind path = + match kind with + | Auto -> + try_symbol_resolvers + [ + (fun () -> resolve_module_references ~package path); + (fun () -> resolve_value_references ~package path); + (fun () -> resolve_type_references ~package path); + ] + | Module -> try_symbol_resolvers [fun () -> resolve_module_references ~package path] + | Value -> try_symbol_resolvers [fun () -> resolve_value_references ~package path] + | Type -> try_symbol_resolvers [fun () -> resolve_type_references ~package path] + +let resolve_location_references ~file_path ~line ~col = + match Cmt.loadFullCmtFromPath ~path:file_path with + | None -> + Error + (Printf.sprintf "error: failed to load typed info for %s" file_path) + | Some full -> ( + match References.getLocItem ~full ~pos:(line - 1, col - 1) ~debug:false with + | None -> + Error + (Printf.sprintf "error: could not resolve a symbol at %s:%d:%d" file_path + line col) + | Some loc_item -> Ok (references_from_loc_item full loc_item)) + +let snippet_of_reference ~source_cache (reference : raw_reference) = + match reference.loc with + | None -> None + | Some loc -> + Lint_support.Snippet.of_loc ~source_cache ~path:reference.abs_path ~loc () + +let stringify_text_reference ~display_base ~source_cache + (reference : raw_reference) = + let path = Lint_support.Path.display ~base:display_base reference.abs_path in + let lines = [Printf.sprintf "path: %s" path] in + let lines = + match reference.loc with + | None -> lines + | Some loc -> + let range = Lint_support.Range.of_loc loc |> Lint_support.Range.to_text in + lines @ [Printf.sprintf "range: %s" range] + in + match snippet_of_reference ~source_cache reference with + | None -> String.concat "\n" lines + | Some snippet -> String.concat "\n" (lines @ ["snippet:"; snippet]) + +let render_text ~display_base ~query_info references = + let header = + match query_info with + | SymbolQuery {symbol_path; resolved_kind} -> + [ + "mode: symbol"; + Printf.sprintf "symbol: %s" symbol_path; + Printf.sprintf "kind: %s" + (Lint_support.SymbolKind.to_string resolved_kind); + Printf.sprintf "count: %d" (List.length references); + ] + | LocationQuery {abs_path; line; col} -> + [ + "mode: location"; + Printf.sprintf "path: %s" + (Lint_support.Path.display ~base:display_base abs_path); + Printf.sprintf "position: %d:%d" line col; + Printf.sprintf "count: %d" (List.length references); + ] + in + match references with + | [] -> String.concat "\n" header + | _ -> + let source_cache = Lint_support.Snippet.create_cache () in + let body = + references + |> List.map (stringify_text_reference ~display_base ~source_cache) + |> String.concat "\n\n" + in + String.concat "\n" header ^ "\n\n" ^ body + +let run_symbol ~symbol_path ~kind ?context_path () = + let context_path = + match context_path with + | Some path -> normalize_path path + | None -> Sys.getcwd () + in + if not (Files.exists context_path) then + Error ("error: no such file or directory: " ^ context_path) + else + match Lint_support.SymbolPath.split symbol_path with + | [] -> Error "error: expected a symbol path like String.localeCompare" + | path -> ( + match package_for_path context_path with + | None -> + Error + ("error: failed to load ReScript project context from " ^ context_path) + | Some package -> ( + match resolve_symbol_references ~package ~kind path with + | Error _ as error -> error + | Ok None -> + Error + (Printf.sprintf "error: could not resolve %s as %s" symbol_path + (Lint_support.SymbolKind.to_string kind)) + | Ok (Some (resolved_kind, references)) -> + let display_base = display_base_for_path context_path in + Ok + { + output = + render_text ~display_base + ~query_info:(SymbolQuery {symbol_path; resolved_kind}) + references; + count = List.length references; + })) + +let run_location ~file_path ~line ~col = + let file_path = normalize_path file_path in + if not (Files.exists file_path) then + Error ("error: no such file or directory: " ^ file_path) + else if line <= 0 || col <= 0 then + Error "error: line and col must be 1-based positive integers" + else + resolve_location_references ~file_path ~line ~col + |> Result.map (fun references -> + let display_base = display_base_for_path file_path in + { + output = + render_text ~display_base + ~query_info:(LocationQuery {abs_path = file_path; line; col}) + references; + count = List.length references; + }) + +let run (query : query) = + match query with + | Symbol {symbol_path; kind; context_path} -> + run_symbol ?context_path ~symbol_path ~kind () + | Location {file_path; line; col} -> run_location ~file_path ~line ~col diff --git a/tools/src/tools.ml b/tools/src/tools.ml index 350dd92a75a..7aa91063acd 100644 --- a/tools/src/tools.ml +++ b/tools/src/tools.ml @@ -1298,3 +1298,4 @@ end module Migrate = Migrate module Lint = Lint module Show = Show +module Find_references = Find_references From 3b4a962e572aeee13d17f94b84a41878c9beea56 Mon Sep 17 00:00:00 2001 From: Gabriel Nordeborn Date: Sun, 19 Apr 2026 15:34:30 +0200 Subject: [PATCH 06/17] add rewrite command --- tests/tools_tests/.rescript-lint.json | 28 +- .../RewriteClean.res.rewrite.diff.expected | 4 + ...ewriteClean.res.rewrite.diff.json.expected | 1 + .../RewriteClean.res.rewrite.expected | 4 + .../RewriteClean.res.rewrite.json.expected | 1 + .../RewriteClean.res.rewrite.source.expected | 14 + .../RewriteIfs.res.rewrite.diff.expected | 45 ++ .../RewriteIfs.res.rewrite.diff.json.expected | 1 + .../expected/RewriteIfs.res.rewrite.expected | 6 + .../RewriteIfs.res.rewrite.json.expected | 1 + .../RewriteIfs.res.rewrite.source.expected | 22 + ...riteOptionalSome.res.rewrite.diff.expected | 28 + ...ptionalSome.res.rewrite.diff.json.expected | 1 + .../RewriteOptionalSome.res.rewrite.expected | 7 + ...riteOptionalSome.res.rewrite.json.expected | 1 + ...teOptionalSome.res.rewrite.source.expected | 12 + .../src/expected/rewrite-root.diff.expected | 75 ++ .../expected/rewrite-root.diff.json.expected | 1 + .../src/expected/rewrite-root.expected | 15 + .../src/expected/rewrite-root.json.expected | 1 + .../tools_tests/src/rewrite/RewriteClean.res | 14 + tests/tools_tests/src/rewrite/RewriteIfs.res | 16 + .../src/rewrite/RewriteOptionalSome.res | 6 + tests/tools_tests/test.sh | 70 +- tools/bin/main.ml | 53 ++ tools/src/rewrite.ml | 655 ++++++++++++++++++ tools/src/tools.ml | 2 +- 27 files changed, 1075 insertions(+), 9 deletions(-) create mode 100644 tests/tools_tests/src/expected/RewriteClean.res.rewrite.diff.expected create mode 100644 tests/tools_tests/src/expected/RewriteClean.res.rewrite.diff.json.expected create mode 100644 tests/tools_tests/src/expected/RewriteClean.res.rewrite.expected create mode 100644 tests/tools_tests/src/expected/RewriteClean.res.rewrite.json.expected create mode 100644 tests/tools_tests/src/expected/RewriteClean.res.rewrite.source.expected create mode 100644 tests/tools_tests/src/expected/RewriteIfs.res.rewrite.diff.expected create mode 100644 tests/tools_tests/src/expected/RewriteIfs.res.rewrite.diff.json.expected create mode 100644 tests/tools_tests/src/expected/RewriteIfs.res.rewrite.expected create mode 100644 tests/tools_tests/src/expected/RewriteIfs.res.rewrite.json.expected create mode 100644 tests/tools_tests/src/expected/RewriteIfs.res.rewrite.source.expected create mode 100644 tests/tools_tests/src/expected/RewriteOptionalSome.res.rewrite.diff.expected create mode 100644 tests/tools_tests/src/expected/RewriteOptionalSome.res.rewrite.diff.json.expected create mode 100644 tests/tools_tests/src/expected/RewriteOptionalSome.res.rewrite.expected create mode 100644 tests/tools_tests/src/expected/RewriteOptionalSome.res.rewrite.json.expected create mode 100644 tests/tools_tests/src/expected/RewriteOptionalSome.res.rewrite.source.expected create mode 100644 tests/tools_tests/src/expected/rewrite-root.diff.expected create mode 100644 tests/tools_tests/src/expected/rewrite-root.diff.json.expected create mode 100644 tests/tools_tests/src/expected/rewrite-root.expected create mode 100644 tests/tools_tests/src/expected/rewrite-root.json.expected create mode 100644 tests/tools_tests/src/rewrite/RewriteClean.res create mode 100644 tests/tools_tests/src/rewrite/RewriteIfs.res create mode 100644 tests/tools_tests/src/rewrite/RewriteOptionalSome.res create mode 100644 tools/src/rewrite.ml diff --git a/tests/tools_tests/.rescript-lint.json b/tests/tools_tests/.rescript-lint.json index dbb69adb7c5..39d5cda83ee 100644 --- a/tests/tools_tests/.rescript-lint.json +++ b/tests/tools_tests/.rescript-lint.json @@ -1,11 +1,25 @@ { - "rules": { - "forbidden-reference": { - "severity": "error", - "items": ["Belt.Array.forEach", "Belt.Array.map", "Js.Json.t"] - }, - "single-use-function": { - "severity": "warning" + "lint": { + "rules": { + "forbidden-reference": { + "severity": "error", + "items": ["Belt.Array.forEach", "Belt.Array.map", "Js.Json.t"] + }, + "single-use-function": { + "severity": "warning" + } + } + }, + "rewrite": { + "rules": { + "prefer-switch": { + "enabled": true, + "if": true, + "ternary": true + }, + "no-optional-some": { + "enabled": true + } } } } diff --git a/tests/tools_tests/src/expected/RewriteClean.res.rewrite.diff.expected b/tests/tools_tests/src/expected/RewriteClean.res.rewrite.diff.expected new file mode 100644 index 00000000000..a7c4d6144b0 --- /dev/null +++ b/tests/tools_tests/src/expected/RewriteClean.res.rewrite.diff.expected @@ -0,0 +1,4 @@ +path: src/rewrite/RewriteClean.res +status: unchanged + +summary: previewed 0 files, unchanged 1 diff --git a/tests/tools_tests/src/expected/RewriteClean.res.rewrite.diff.json.expected b/tests/tools_tests/src/expected/RewriteClean.res.rewrite.diff.json.expected new file mode 100644 index 00000000000..98a48e0cb00 --- /dev/null +++ b/tests/tools_tests/src/expected/RewriteClean.res.rewrite.diff.json.expected @@ -0,0 +1 @@ +[{"path":"src/rewrite/RewriteClean.res","mode":"diff","changed":false,"applied":[]}] diff --git a/tests/tools_tests/src/expected/RewriteClean.res.rewrite.expected b/tests/tools_tests/src/expected/RewriteClean.res.rewrite.expected new file mode 100644 index 00000000000..0063d141063 --- /dev/null +++ b/tests/tools_tests/src/expected/RewriteClean.res.rewrite.expected @@ -0,0 +1,4 @@ +path: .tmp-rewrite-tests/RewriteClean.res +status: unchanged + +summary: rewritten 0 files, unchanged 1 diff --git a/tests/tools_tests/src/expected/RewriteClean.res.rewrite.json.expected b/tests/tools_tests/src/expected/RewriteClean.res.rewrite.json.expected new file mode 100644 index 00000000000..2f624625c2c --- /dev/null +++ b/tests/tools_tests/src/expected/RewriteClean.res.rewrite.json.expected @@ -0,0 +1 @@ +[{"path":".tmp-rewrite-tests/RewriteClean.res","mode":"write","changed":false,"applied":[]}] diff --git a/tests/tools_tests/src/expected/RewriteClean.res.rewrite.source.expected b/tests/tools_tests/src/expected/RewriteClean.res.rewrite.source.expected new file mode 100644 index 00000000000..124ac5bc2cc --- /dev/null +++ b/tests/tools_tests/src/expected/RewriteClean.res.rewrite.source.expected @@ -0,0 +1,14 @@ +let flag = true +let useValue = (~value=?, ()) => value + +let direct = switch flag { +| true => 1 +| false => 2 +} + +let withGuard = switch () { +| _ if flag => 1 +| _ => 2 +} + +let value = useValue(~value=1, ()) diff --git a/tests/tools_tests/src/expected/RewriteIfs.res.rewrite.diff.expected b/tests/tools_tests/src/expected/RewriteIfs.res.rewrite.diff.expected new file mode 100644 index 00000000000..e33ca6ef729 --- /dev/null +++ b/tests/tools_tests/src/expected/RewriteIfs.res.rewrite.diff.expected @@ -0,0 +1,45 @@ +path: src/rewrite/RewriteIfs.res +status: preview +rules: +- prefer-switch(4): rewrote `if` / ternary branches into `switch` +diff: +```diff +--- a/src/rewrite/RewriteIfs.res ++++ b/src/rewrite/RewriteIfs.res +@@ -1,16 +1,22 @@ + let flag = true + let other = false + +-let direct = if flag { 1 } else { 2 } +-let ternary = flag ? "yes" : "no" ++let direct = switch flag { ++| true => 1 ++| false => 2 ++} ++let ternary = switch flag { ++| true => "yes" ++| false => "no" ++} + +-let chained = +- if flag { +- 1 +- } else if other { +- 2 +- } else { +- 3 +- } ++let chained = switch () { ++| _ if flag => 1 ++| _ if other => 2 ++| _ => 3 ++} + +-let onlyWhen = if flag { Console.log("hit") } ++let onlyWhen = switch flag { ++| true => Console.log("hit") ++| false => () ++} +``` + +summary: previewed 1 files, unchanged 0, rules: prefer-switch(4) diff --git a/tests/tools_tests/src/expected/RewriteIfs.res.rewrite.diff.json.expected b/tests/tools_tests/src/expected/RewriteIfs.res.rewrite.diff.json.expected new file mode 100644 index 00000000000..4dafe4e6426 --- /dev/null +++ b/tests/tools_tests/src/expected/RewriteIfs.res.rewrite.diff.json.expected @@ -0,0 +1 @@ +[{"path":"src/rewrite/RewriteIfs.res","mode":"diff","changed":true,"applied":[{"rule":"prefer-switch","count":4,"note":"rewrote `if` / ternary branches into `switch`"}],"diff":"--- a/src/rewrite/RewriteIfs.res\n+++ b/src/rewrite/RewriteIfs.res\n@@ -1,16 +1,22 @@\n let flag = true\n let other = false\n\n-let direct = if flag { 1 } else { 2 }\n-let ternary = flag ? \"yes\" : \"no\"\n+let direct = switch flag {\n+| true => 1\n+| false => 2\n+}\n+let ternary = switch flag {\n+| true => \"yes\"\n+| false => \"no\"\n+}\n\n-let chained =\n- if flag {\n- 1\n- } else if other {\n- 2\n- } else {\n- 3\n- }\n+let chained = switch () {\n+| _ if flag => 1\n+| _ if other => 2\n+| _ => 3\n+}\n\n-let onlyWhen = if flag { Console.log(\"hit\") }\n+let onlyWhen = switch flag {\n+| true => Console.log(\"hit\")\n+| false => ()\n+}","rewritten":"let flag = true\nlet other = false\n\nlet direct = switch flag {\n| true => 1\n| false => 2\n}\nlet ternary = switch flag {\n| true => \"yes\"\n| false => \"no\"\n}\n\nlet chained = switch () {\n| _ if flag => 1\n| _ if other => 2\n| _ => 3\n}\n\nlet onlyWhen = switch flag {\n| true => Console.log(\"hit\")\n| false => ()\n}\n"}] diff --git a/tests/tools_tests/src/expected/RewriteIfs.res.rewrite.expected b/tests/tools_tests/src/expected/RewriteIfs.res.rewrite.expected new file mode 100644 index 00000000000..67834d431e5 --- /dev/null +++ b/tests/tools_tests/src/expected/RewriteIfs.res.rewrite.expected @@ -0,0 +1,6 @@ +path: .tmp-rewrite-tests/RewriteIfs.res +status: rewritten +rules: +- prefer-switch(4): rewrote `if` / ternary branches into `switch` + +summary: rewritten 1 files, unchanged 0, rules: prefer-switch(4) diff --git a/tests/tools_tests/src/expected/RewriteIfs.res.rewrite.json.expected b/tests/tools_tests/src/expected/RewriteIfs.res.rewrite.json.expected new file mode 100644 index 00000000000..c71767f6a8c --- /dev/null +++ b/tests/tools_tests/src/expected/RewriteIfs.res.rewrite.json.expected @@ -0,0 +1 @@ +[{"path":".tmp-rewrite-tests/RewriteIfs.res","mode":"write","changed":true,"applied":[{"rule":"prefer-switch","count":4,"note":"rewrote `if` / ternary branches into `switch`"}]}] diff --git a/tests/tools_tests/src/expected/RewriteIfs.res.rewrite.source.expected b/tests/tools_tests/src/expected/RewriteIfs.res.rewrite.source.expected new file mode 100644 index 00000000000..7699fe15441 --- /dev/null +++ b/tests/tools_tests/src/expected/RewriteIfs.res.rewrite.source.expected @@ -0,0 +1,22 @@ +let flag = true +let other = false + +let direct = switch flag { +| true => 1 +| false => 2 +} +let ternary = switch flag { +| true => "yes" +| false => "no" +} + +let chained = switch () { +| _ if flag => 1 +| _ if other => 2 +| _ => 3 +} + +let onlyWhen = switch flag { +| true => Console.log("hit") +| false => () +} diff --git a/tests/tools_tests/src/expected/RewriteOptionalSome.res.rewrite.diff.expected b/tests/tools_tests/src/expected/RewriteOptionalSome.res.rewrite.diff.expected new file mode 100644 index 00000000000..738aaad0e9e --- /dev/null +++ b/tests/tools_tests/src/expected/RewriteOptionalSome.res.rewrite.diff.expected @@ -0,0 +1,28 @@ +path: src/rewrite/RewriteOptionalSome.res +status: preview +rules: +- prefer-switch(1): rewrote `if` / ternary branches into `switch` +- no-optional-some(3): rewrote `~label=?Some(expr)` into `~label=expr` +diff: +```diff +--- a/src/rewrite/RewriteOptionalSome.res ++++ b/src/rewrite/RewriteOptionalSome.res +@@ -1,6 +1,12 @@ + let flag = true + let useValue = (~value=?, ()) => value + +-let direct = useValue(~value=?Some(1), ()) +-let nested = useValue(~value=?Some(if flag { 1 } else { 2 }), ()) +-let fromComputation = useValue(~value=?Some(String.length("abc")), ()) ++let direct = useValue(~value=1, ()) ++let nested = useValue( ++ ~value=switch flag { ++ | true => 1 ++ | false => 2 ++ }, ++ (), ++) ++let fromComputation = useValue(~value=String.length("abc"), ()) +``` + +summary: previewed 1 files, unchanged 0, rules: prefer-switch(1), no-optional-some(3) diff --git a/tests/tools_tests/src/expected/RewriteOptionalSome.res.rewrite.diff.json.expected b/tests/tools_tests/src/expected/RewriteOptionalSome.res.rewrite.diff.json.expected new file mode 100644 index 00000000000..fbd5781f320 --- /dev/null +++ b/tests/tools_tests/src/expected/RewriteOptionalSome.res.rewrite.diff.json.expected @@ -0,0 +1 @@ +[{"path":"src/rewrite/RewriteOptionalSome.res","mode":"diff","changed":true,"applied":[{"rule":"prefer-switch","count":1,"note":"rewrote `if` / ternary branches into `switch`"}, {"rule":"no-optional-some","count":3,"note":"rewrote `~label=?Some(expr)` into `~label=expr`"}],"diff":"--- a/src/rewrite/RewriteOptionalSome.res\n+++ b/src/rewrite/RewriteOptionalSome.res\n@@ -1,6 +1,12 @@\n let flag = true\n let useValue = (~value=?, ()) => value\n\n-let direct = useValue(~value=?Some(1), ())\n-let nested = useValue(~value=?Some(if flag { 1 } else { 2 }), ())\n-let fromComputation = useValue(~value=?Some(String.length(\"abc\")), ())\n+let direct = useValue(~value=1, ())\n+let nested = useValue(\n+ ~value=switch flag {\n+ | true => 1\n+ | false => 2\n+ },\n+ (),\n+)\n+let fromComputation = useValue(~value=String.length(\"abc\"), ())","rewritten":"let flag = true\nlet useValue = (~value=?, ()) => value\n\nlet direct = useValue(~value=1, ())\nlet nested = useValue(\n ~value=switch flag {\n | true => 1\n | false => 2\n },\n (),\n)\nlet fromComputation = useValue(~value=String.length(\"abc\"), ())\n"}] diff --git a/tests/tools_tests/src/expected/RewriteOptionalSome.res.rewrite.expected b/tests/tools_tests/src/expected/RewriteOptionalSome.res.rewrite.expected new file mode 100644 index 00000000000..42066e95619 --- /dev/null +++ b/tests/tools_tests/src/expected/RewriteOptionalSome.res.rewrite.expected @@ -0,0 +1,7 @@ +path: .tmp-rewrite-tests/RewriteOptionalSome.res +status: rewritten +rules: +- prefer-switch(1): rewrote `if` / ternary branches into `switch` +- no-optional-some(3): rewrote `~label=?Some(expr)` into `~label=expr` + +summary: rewritten 1 files, unchanged 0, rules: prefer-switch(1), no-optional-some(3) diff --git a/tests/tools_tests/src/expected/RewriteOptionalSome.res.rewrite.json.expected b/tests/tools_tests/src/expected/RewriteOptionalSome.res.rewrite.json.expected new file mode 100644 index 00000000000..b0f35646848 --- /dev/null +++ b/tests/tools_tests/src/expected/RewriteOptionalSome.res.rewrite.json.expected @@ -0,0 +1 @@ +[{"path":".tmp-rewrite-tests/RewriteOptionalSome.res","mode":"write","changed":true,"applied":[{"rule":"prefer-switch","count":1,"note":"rewrote `if` / ternary branches into `switch`"}, {"rule":"no-optional-some","count":3,"note":"rewrote `~label=?Some(expr)` into `~label=expr`"}]}] diff --git a/tests/tools_tests/src/expected/RewriteOptionalSome.res.rewrite.source.expected b/tests/tools_tests/src/expected/RewriteOptionalSome.res.rewrite.source.expected new file mode 100644 index 00000000000..4a3bf293d79 --- /dev/null +++ b/tests/tools_tests/src/expected/RewriteOptionalSome.res.rewrite.source.expected @@ -0,0 +1,12 @@ +let flag = true +let useValue = (~value=?, ()) => value + +let direct = useValue(~value=1, ()) +let nested = useValue( + ~value=switch flag { + | true => 1 + | false => 2 + }, + (), +) +let fromComputation = useValue(~value=String.length("abc"), ()) diff --git a/tests/tools_tests/src/expected/rewrite-root.diff.expected b/tests/tools_tests/src/expected/rewrite-root.diff.expected new file mode 100644 index 00000000000..a2821d6c33b --- /dev/null +++ b/tests/tools_tests/src/expected/rewrite-root.diff.expected @@ -0,0 +1,75 @@ +path: src/rewrite/RewriteClean.res +status: unchanged + +path: src/rewrite/RewriteIfs.res +status: preview +rules: +- prefer-switch(4): rewrote `if` / ternary branches into `switch` +diff: +```diff +--- a/src/rewrite/RewriteIfs.res ++++ b/src/rewrite/RewriteIfs.res +@@ -1,16 +1,22 @@ + let flag = true + let other = false + +-let direct = if flag { 1 } else { 2 } +-let ternary = flag ? "yes" : "no" ++let direct = switch flag { ++| true => 1 ++| false => 2 ++} ++let ternary = switch flag { ++| true => "yes" ++| false => "no" ++} + +-let chained = +- if flag { +- 1 +- } else if other { +- 2 +- } else { +- 3 +- } ++let chained = switch () { ++| _ if flag => 1 ++| _ if other => 2 ++| _ => 3 ++} + +-let onlyWhen = if flag { Console.log("hit") } ++let onlyWhen = switch flag { ++| true => Console.log("hit") ++| false => () ++} +``` + +path: src/rewrite/RewriteOptionalSome.res +status: preview +rules: +- prefer-switch(1): rewrote `if` / ternary branches into `switch` +- no-optional-some(3): rewrote `~label=?Some(expr)` into `~label=expr` +diff: +```diff +--- a/src/rewrite/RewriteOptionalSome.res ++++ b/src/rewrite/RewriteOptionalSome.res +@@ -1,6 +1,12 @@ + let flag = true + let useValue = (~value=?, ()) => value + +-let direct = useValue(~value=?Some(1), ()) +-let nested = useValue(~value=?Some(if flag { 1 } else { 2 }), ()) +-let fromComputation = useValue(~value=?Some(String.length("abc")), ()) ++let direct = useValue(~value=1, ()) ++let nested = useValue( ++ ~value=switch flag { ++ | true => 1 ++ | false => 2 ++ }, ++ (), ++) ++let fromComputation = useValue(~value=String.length("abc"), ()) +``` + +summary: previewed 2 files, unchanged 1, rules: prefer-switch(5), no-optional-some(3) diff --git a/tests/tools_tests/src/expected/rewrite-root.diff.json.expected b/tests/tools_tests/src/expected/rewrite-root.diff.json.expected new file mode 100644 index 00000000000..b18b5f04a57 --- /dev/null +++ b/tests/tools_tests/src/expected/rewrite-root.diff.json.expected @@ -0,0 +1 @@ +[{"path":"src/rewrite/RewriteClean.res","mode":"diff","changed":false,"applied":[]},{"path":"src/rewrite/RewriteIfs.res","mode":"diff","changed":true,"applied":[{"rule":"prefer-switch","count":4,"note":"rewrote `if` / ternary branches into `switch`"}],"diff":"--- a/src/rewrite/RewriteIfs.res\n+++ b/src/rewrite/RewriteIfs.res\n@@ -1,16 +1,22 @@\n let flag = true\n let other = false\n\n-let direct = if flag { 1 } else { 2 }\n-let ternary = flag ? \"yes\" : \"no\"\n+let direct = switch flag {\n+| true => 1\n+| false => 2\n+}\n+let ternary = switch flag {\n+| true => \"yes\"\n+| false => \"no\"\n+}\n\n-let chained =\n- if flag {\n- 1\n- } else if other {\n- 2\n- } else {\n- 3\n- }\n+let chained = switch () {\n+| _ if flag => 1\n+| _ if other => 2\n+| _ => 3\n+}\n\n-let onlyWhen = if flag { Console.log(\"hit\") }\n+let onlyWhen = switch flag {\n+| true => Console.log(\"hit\")\n+| false => ()\n+}","rewritten":"let flag = true\nlet other = false\n\nlet direct = switch flag {\n| true => 1\n| false => 2\n}\nlet ternary = switch flag {\n| true => \"yes\"\n| false => \"no\"\n}\n\nlet chained = switch () {\n| _ if flag => 1\n| _ if other => 2\n| _ => 3\n}\n\nlet onlyWhen = switch flag {\n| true => Console.log(\"hit\")\n| false => ()\n}\n"},{"path":"src/rewrite/RewriteOptionalSome.res","mode":"diff","changed":true,"applied":[{"rule":"prefer-switch","count":1,"note":"rewrote `if` / ternary branches into `switch`"}, {"rule":"no-optional-some","count":3,"note":"rewrote `~label=?Some(expr)` into `~label=expr`"}],"diff":"--- a/src/rewrite/RewriteOptionalSome.res\n+++ b/src/rewrite/RewriteOptionalSome.res\n@@ -1,6 +1,12 @@\n let flag = true\n let useValue = (~value=?, ()) => value\n\n-let direct = useValue(~value=?Some(1), ())\n-let nested = useValue(~value=?Some(if flag { 1 } else { 2 }), ())\n-let fromComputation = useValue(~value=?Some(String.length(\"abc\")), ())\n+let direct = useValue(~value=1, ())\n+let nested = useValue(\n+ ~value=switch flag {\n+ | true => 1\n+ | false => 2\n+ },\n+ (),\n+)\n+let fromComputation = useValue(~value=String.length(\"abc\"), ())","rewritten":"let flag = true\nlet useValue = (~value=?, ()) => value\n\nlet direct = useValue(~value=1, ())\nlet nested = useValue(\n ~value=switch flag {\n | true => 1\n | false => 2\n },\n (),\n)\nlet fromComputation = useValue(~value=String.length(\"abc\"), ())\n"}] diff --git a/tests/tools_tests/src/expected/rewrite-root.expected b/tests/tools_tests/src/expected/rewrite-root.expected new file mode 100644 index 00000000000..68270d05c2c --- /dev/null +++ b/tests/tools_tests/src/expected/rewrite-root.expected @@ -0,0 +1,15 @@ +path: .tmp-rewrite-tests/root/RewriteClean.res +status: unchanged + +path: .tmp-rewrite-tests/root/RewriteIfs.res +status: rewritten +rules: +- prefer-switch(4): rewrote `if` / ternary branches into `switch` + +path: .tmp-rewrite-tests/root/RewriteOptionalSome.res +status: rewritten +rules: +- prefer-switch(1): rewrote `if` / ternary branches into `switch` +- no-optional-some(3): rewrote `~label=?Some(expr)` into `~label=expr` + +summary: rewritten 2 files, unchanged 1, rules: prefer-switch(5), no-optional-some(3) diff --git a/tests/tools_tests/src/expected/rewrite-root.json.expected b/tests/tools_tests/src/expected/rewrite-root.json.expected new file mode 100644 index 00000000000..a4e90968ab5 --- /dev/null +++ b/tests/tools_tests/src/expected/rewrite-root.json.expected @@ -0,0 +1 @@ +[{"path":".tmp-rewrite-tests/root/RewriteClean.res","mode":"write","changed":false,"applied":[]},{"path":".tmp-rewrite-tests/root/RewriteIfs.res","mode":"write","changed":true,"applied":[{"rule":"prefer-switch","count":4,"note":"rewrote `if` / ternary branches into `switch`"}]},{"path":".tmp-rewrite-tests/root/RewriteOptionalSome.res","mode":"write","changed":true,"applied":[{"rule":"prefer-switch","count":1,"note":"rewrote `if` / ternary branches into `switch`"}, {"rule":"no-optional-some","count":3,"note":"rewrote `~label=?Some(expr)` into `~label=expr`"}]}] diff --git a/tests/tools_tests/src/rewrite/RewriteClean.res b/tests/tools_tests/src/rewrite/RewriteClean.res new file mode 100644 index 00000000000..124ac5bc2cc --- /dev/null +++ b/tests/tools_tests/src/rewrite/RewriteClean.res @@ -0,0 +1,14 @@ +let flag = true +let useValue = (~value=?, ()) => value + +let direct = switch flag { +| true => 1 +| false => 2 +} + +let withGuard = switch () { +| _ if flag => 1 +| _ => 2 +} + +let value = useValue(~value=1, ()) diff --git a/tests/tools_tests/src/rewrite/RewriteIfs.res b/tests/tools_tests/src/rewrite/RewriteIfs.res new file mode 100644 index 00000000000..bdcc91eb38a --- /dev/null +++ b/tests/tools_tests/src/rewrite/RewriteIfs.res @@ -0,0 +1,16 @@ +let flag = true +let other = false + +let direct = if flag { 1 } else { 2 } +let ternary = flag ? "yes" : "no" + +let chained = + if flag { + 1 + } else if other { + 2 + } else { + 3 + } + +let onlyWhen = if flag { Console.log("hit") } diff --git a/tests/tools_tests/src/rewrite/RewriteOptionalSome.res b/tests/tools_tests/src/rewrite/RewriteOptionalSome.res new file mode 100644 index 00000000000..bf5958b2c21 --- /dev/null +++ b/tests/tools_tests/src/rewrite/RewriteOptionalSome.res @@ -0,0 +1,6 @@ +let flag = true +let useValue = (~value=?, ()) => value + +let direct = useValue(~value=?Some(1), ()) +let nested = useValue(~value=?Some(if flag { 1 } else { 2 }), ()) +let fromComputation = useValue(~value=?Some(String.length("abc")), ()) diff --git a/tests/tools_tests/test.sh b/tests/tools_tests/test.sh index 355418e03da..572768c7c40 100755 --- a/tests/tools_tests/test.sh +++ b/tests/tools_tests/test.sh @@ -110,7 +110,6 @@ fi ../../_build/install/default/bin/rescript-tools show String --kind module > /dev/null || exit 1 - # Test find-references command ../../_build/install/default/bin/rescript-tools find-references FindReferencesFixture.makeGreeting --kind value > src/expected/find-references-FindReferencesFixture.makeGreeting.expected || exit 1 if [ "$RUNNER_OS" == "Windows" ]; then @@ -123,6 +122,75 @@ if [ "$RUNNER_OS" == "Windows" ]; then fi ../../_build/install/default/bin/rescript-tools find-references FindReferencesFixture --kind module > /dev/null || exit 1 + +# Test rewrite command +rm -rf .tmp-rewrite-tests +mkdir -p .tmp-rewrite-tests + +for file in src/rewrite/*.res; do + tmp_file=".tmp-rewrite-tests/$(basename $file)" + cp "$file" "$tmp_file" + + output="src/expected/$(basename $file).rewrite.expected" + ../../_build/install/default/bin/rescript-tools rewrite "$tmp_file" > "$output" + if [ "$RUNNER_OS" == "Windows" ]; then + perl -pi -e 's/\r\n/\n/g' -- "$output" + fi + + rewritten_output="src/expected/$(basename $file).rewrite.source.expected" + cat "$tmp_file" > "$rewritten_output" + if [ "$RUNNER_OS" == "Windows" ]; then + perl -pi -e 's/\r\n/\n/g' -- "$rewritten_output" + fi + + cp "$file" "$tmp_file" + json_output="src/expected/$(basename $file).rewrite.json.expected" + ../../_build/install/default/bin/rescript-tools rewrite "$tmp_file" --json > "$json_output" + if [ "$RUNNER_OS" == "Windows" ]; then + perl -pi -e 's/\r\n/\n/g' -- "$json_output" + fi + + diff_output="src/expected/$(basename $file).rewrite.diff.expected" + ../../_build/install/default/bin/rescript-tools rewrite "$file" --diff > "$diff_output" + if [ "$RUNNER_OS" == "Windows" ]; then + perl -pi -e 's/\r\n/\n/g' -- "$diff_output" + fi + + diff_json_output="src/expected/$(basename $file).rewrite.diff.json.expected" + ../../_build/install/default/bin/rescript-tools rewrite "$file" --diff --json > "$diff_json_output" + if [ "$RUNNER_OS" == "Windows" ]; then + perl -pi -e 's/\r\n/\n/g' -- "$diff_json_output" + fi +done + +rm -rf .tmp-rewrite-tests/root +mkdir -p .tmp-rewrite-tests/root +cp src/rewrite/*.res .tmp-rewrite-tests/root/ +../../_build/install/default/bin/rescript-tools rewrite .tmp-rewrite-tests/root > src/expected/rewrite-root.expected +if [ "$RUNNER_OS" == "Windows" ]; then + perl -pi -e 's/\r\n/\n/g' -- src/expected/rewrite-root.expected +fi + +rm -rf .tmp-rewrite-tests/root +mkdir -p .tmp-rewrite-tests/root +cp src/rewrite/*.res .tmp-rewrite-tests/root/ +../../_build/install/default/bin/rescript-tools rewrite .tmp-rewrite-tests/root --json > src/expected/rewrite-root.json.expected +if [ "$RUNNER_OS" == "Windows" ]; then + perl -pi -e 's/\r\n/\n/g' -- src/expected/rewrite-root.json.expected +fi + +../../_build/install/default/bin/rescript-tools rewrite src/rewrite --diff > src/expected/rewrite-root.diff.expected +if [ "$RUNNER_OS" == "Windows" ]; then + perl -pi -e 's/\r\n/\n/g' -- src/expected/rewrite-root.diff.expected +fi + +../../_build/install/default/bin/rescript-tools rewrite src/rewrite --diff --json > src/expected/rewrite-root.diff.json.expected +if [ "$RUNNER_OS" == "Windows" ]; then + perl -pi -e 's/\r\n/\n/g' -- src/expected/rewrite-root.diff.json.expected +fi + +rm -rf .tmp-rewrite-tests + # Test migrate command for file in src/migrate/*.{res,resi}; do output="src/expected/$(basename $file).expected" diff --git a/tools/bin/main.ml b/tools/bin/main.ml index 59b1f234d89..95ebbd6cef6 100644 --- a/tools/bin/main.ml +++ b/tools/bin/main.ml @@ -39,6 +39,21 @@ Notes: Example: rescript-tools lint ./src/MyModule.res|} +let rewriteHelp = + {|ReScript Tools + +Rewrite a file or directory into a narrower agent-oriented source form + +Usage: rescript-tools rewrite [--config ] [--diff] [--json] + +Notes: +- Files are rewritten in place +- Use --diff to preview the rewritten diff without modifying files +- Use --json for compact machine-readable output +- Rewrite rules are loaded from the same config discovery path as lint + +Example: rescript-tools rewrite ./src/MyModule.res|} + let activeRulesHelp = {|ReScript Tools @@ -102,9 +117,23 @@ extract-codeblocks Extract ReScript code blocks from file lint Run AI-oriented lint checks [--config ] Use the given lint config file [--json] Output compact JSON +rewrite Rewrite source into agent-oriented normal form + [--config ] Use the given lint config file + [--diff] Preview the rewritten diff without writing files + [--json] Output compact JSON active-rules List lint/rewrite rules and whether they are active [--config ] Use the given lint config file [--json] Output compact JSON +show Show hover-style semantic information + [--kind ] Select what kind of symbol to resolve + [--context ] Resolve within the package for this path + [--comments ] Include or omit doc/comments in output +find-references Find references by symbol path + [--kind ] Select what kind of symbol to resolve + [--context ] Resolve within the package for this path +find-references --file Find references at a source location + [--line ] Use 1-based line number + [--col ] Use 1-based column number reanalyze Reanalyze reanalyze-server Start reanalyze server -v, --version Print version @@ -257,6 +286,30 @@ let main () = if output <> "" then print_endline output; exit (if has_findings then 1 else 0))) | _ -> logAndExit (Error lintHelp)) + | "rewrite" :: rest -> ( + match rest with + | ["-h"] | ["--help"] -> logAndExit (Ok rewriteHelp) + | path :: args -> ( + let rec parse_args config_path json diff = function + | [] -> Ok (config_path, json, diff) + | "--json" :: rest -> parse_args config_path true diff rest + | "--diff" :: rest -> parse_args config_path json true rest + | "--config" :: config :: rest -> + parse_args (Some config) json diff rest + | _ -> Error rewriteHelp + in + match parse_args None false false args with + | Error help -> logAndExit (Error help) + | Ok (config_path, json, diff) -> ( + let mode = if diff then Tools.Rewrite.Diff else Tools.Rewrite.Write in + match Tools.Rewrite.run ?config_path ~json ~mode path with + | Error err -> + prerr_endline err; + exit 2 + | Ok {Tools.Rewrite.output; _} -> + if output <> "" then print_endline output; + exit 0)) + | _ -> logAndExit (Error rewriteHelp)) | "active-rules" :: rest -> ( match rest with | ["-h"] | ["--help"] -> logAndExit (Ok activeRulesHelp) diff --git a/tools/src/rewrite.ml b/tools/src/rewrite.ml new file mode 100644 index 00000000000..ee77752fe6c --- /dev/null +++ b/tools/src/rewrite.ml @@ -0,0 +1,655 @@ +open Analysis +open SharedTypes +open Lint_shared + +type mode = Write | Diff + +type applied_rule = {rule: string; count: int; note: string} + +type file_result = { + abs_path: string; + changed: bool; + applied_rules: applied_rule list; + diff: string option; + rewritten_contents: string option; +} + +type rewritten_file = { + changed: bool; + source_contents: string; + contents: string; + applied_rules: applied_rule list; +} + +type run_result = {output: string; changed_files: int} + +type rule_counts = {prefer_switch: int; no_optional_some: int} + +let package_for_path path = + let uri = Uri.fromPath path in + Packages.getPackage ~uri + +let is_blank_line line = String.trim line = "" + +let source_files_in_package package = + package.projectFiles |> FileSet.elements + |> List.filter_map (fun module_name -> + Hashtbl.find_opt package.pathsForModule module_name + |> Option.map getSrc) + |> List.concat + |> List.filter FindFiles.isSourceFile + +let collect_files target_path = + if Lint_support.Path.is_directory target_path then + match package_for_path target_path with + | Some package -> + let package_files = + source_files_in_package package + |> List.filter (fun file -> Files.pathStartsWith file target_path) + |> List.sort_uniq String.compare + in + if package_files = [] then + Files.collect target_path FindFiles.isSourceFile + |> List.sort_uniq String.compare + else package_files + | None -> + Files.collect target_path FindFiles.isSourceFile + |> List.sort_uniq String.compare + else if FindFiles.isSourceFile target_path then [target_path] + else [] + +let display_base target_path files = + match files with + | file :: _ -> ( + match package_for_path file with + | Some package -> package.rootPath + | None -> + if Lint_support.Path.is_directory target_path then target_path + else Filename.dirname target_path) + | [] -> + if Lint_support.Path.is_directory target_path then target_path + else Filename.dirname target_path + +let is_ternary_attribute ((name, _payload) : Parsetree.attribute) = + match name.Location.txt with + | "res.ternary" | "ns.ternary" -> true + | _ -> false + +let filter_ternary_attributes attrs = + attrs |> List.filter (fun attr -> not (is_ternary_attribute attr)) + +let has_non_ternary_attributes attrs = + attrs |> List.exists (fun attr -> not (is_ternary_attribute attr)) + +let is_ternary_expr (expr : Parsetree.expression) = + match expr.pexp_desc with + | Pexp_ifthenelse _ -> + expr.pexp_attributes |> List.exists is_ternary_attribute + | _ -> false + +let should_rewrite_if ~(config : config) expr = + if not config.rewrite.prefer_switch.enabled then false + else if is_ternary_expr expr then config.rewrite.prefer_switch.rewrite_ternary + else config.rewrite.prefer_switch.rewrite_if + +let mklident ~loc txt = Location.mkloc (Longident.Lident txt) loc + +let unit_expression ~loc = + Ast_helper.Exp.construct ~loc (mklident ~loc "()") None + +let bool_pattern ~loc value = + Ast_helper.Pat.construct ~loc + (mklident ~loc (if value then "true" else "false")) + None + +let make_boolean_switch ~loc ~attrs condition then_expr else_expr = + Ast_helper.Exp.match_ ~loc ~attrs condition + [ + Ast_helper.Exp.case (bool_pattern ~loc true) then_expr; + Ast_helper.Exp.case (bool_pattern ~loc false) else_expr; + ] + +let make_guard_switch ~loc ~attrs branches fallback = + let wildcard = Ast_helper.Pat.any ~loc () in + let cases = + branches + |> List.map (fun (condition, expr) -> + Ast_helper.Exp.case wildcard ~guard:condition expr) + in + Ast_helper.Exp.match_ ~loc ~attrs (unit_expression ~loc) + (cases @ [Ast_helper.Exp.case wildcard fallback]) + +let verify_rewritten_source ~path ~contents = + if Filename.check_suffix path ".resi" then + let {Res_driver.invalid; diagnostics; _} = + Res_driver.parse_interface_from_source ~for_printer:true + ~display_filename:path ~source:contents + in + if invalid then + let buf = Buffer.create 256 in + let formatter = Format.formatter_of_buffer buf in + Res_diagnostics.print_report ~formatter diagnostics contents; + Format.pp_print_flush formatter (); + Error + (Printf.sprintf + "error: rewrite produced invalid syntax for %s\n%s" path + (Buffer.contents buf)) + else Ok () + else + let {Res_driver.invalid; diagnostics; _} = + Res_driver.parse_implementation_from_source ~for_printer:true + ~display_filename:path ~source:contents + in + if invalid then + let buf = Buffer.create 256 in + let formatter = Format.formatter_of_buffer buf in + Res_diagnostics.print_report ~formatter diagnostics contents; + Format.pp_print_flush formatter (); + Error + (Printf.sprintf + "error: rewrite produced invalid syntax for %s\n%s" path + (Buffer.contents buf)) + else Ok () + +let applied_rules_of_counts (rule_counts : rule_counts) = + [ + ("prefer-switch", rule_counts.prefer_switch); + ("no-optional-some", rule_counts.no_optional_some); + ] + |> List.filter_map (fun (rule, count) -> + if count <= 0 then None + else Some {rule; count; note = rewrite_note_for_rule rule}) + +module Diff = struct + type op = Equal of string | Delete of string | Insert of string + + type entry = { + op: op; + old_no: int option; + new_no: int option; + } + + let strip_trailing_cr line = + let len = String.length line in + if len > 0 && line.[len - 1] = '\r' then String.sub line 0 (len - 1) + else line + + let split_lines text = + let lines = String.split_on_char '\n' text |> List.map strip_trailing_cr in + match List.rev lines with + | "" :: rest -> List.rev rest + | _ -> lines + + let build_ops ~before ~after = + let before = Array.of_list (split_lines before) in + let after = Array.of_list (split_lines after) in + let before_len = Array.length before in + let after_len = Array.length after in + let table = Array.make_matrix (before_len + 1) (after_len + 1) 0 in + for before_index = before_len - 1 downto 0 do + for after_index = after_len - 1 downto 0 do + table.(before_index).(after_index) <- + if before.(before_index) = after.(after_index) then + table.(before_index + 1).(after_index + 1) + 1 + else + max table.(before_index + 1).(after_index) + table.(before_index).(after_index + 1) + done + done; + let rec loop before_index after_index acc = + if before_index < before_len && after_index < after_len + && before.(before_index) = after.(after_index) + then + loop (before_index + 1) (after_index + 1) + (Equal before.(before_index) :: acc) + else if before_index < before_len + && (after_index = after_len + || table.(before_index + 1).(after_index) + >= table.(before_index).(after_index + 1)) + then + loop (before_index + 1) after_index + (Delete before.(before_index) :: acc) + else if after_index < after_len then + loop before_index (after_index + 1) + (Insert after.(after_index) :: acc) + else List.rev acc + in + loop 0 0 [] + + let annotate ops = + let old_line = ref 1 in + let new_line = ref 1 in + ops + |> List.map (fun op -> + match op with + | Equal _ -> + let current_old = !old_line in + let current_new = !new_line in + incr old_line; + incr new_line; + {op; old_no = Some current_old; new_no = Some current_new} + | Delete _ -> + let current_old = !old_line in + incr old_line; + {op; old_no = Some current_old; new_no = None} + | Insert _ -> + let current_new = !new_line in + incr new_line; + {op; old_no = None; new_no = Some current_new}) + |> Array.of_list + + let is_change (entry : entry) = + match entry.op with + | Equal _ -> false + | Delete _ | Insert _ -> true + + let count_lines entries start finish get_line = + let count = ref 0 in + for index = start to finish do + if Option.is_some (get_line entries.(index)) then incr count + done; + !count + + let find_forward entries start finish get_line = + let rec loop index = + if index > finish then None + else + match get_line entries.(index) with + | Some _ as line -> line + | None -> loop (index + 1) + in + loop start + + let find_backward entries start get_line = + let rec loop index = + if index < 0 then None + else + match get_line entries.(index) with + | Some _ as line -> line + | None -> loop (index - 1) + in + loop start + + let line_start entries start finish get_line = + match find_forward entries start finish get_line with + | Some line -> line + | None -> ( + match find_backward entries (start - 1) get_line with + | Some line -> line + 1 + | None -> 1) + + let render_hunk entries start finish = + let old_start = line_start entries start finish (fun entry -> entry.old_no) in + let new_start = line_start entries start finish (fun entry -> entry.new_no) in + let old_count = + count_lines entries start finish (fun entry -> entry.old_no) + in + let new_count = + count_lines entries start finish (fun entry -> entry.new_no) + in + let header = + Printf.sprintf "@@ -%d,%d +%d,%d @@" old_start old_count new_start + new_count + in + let lines = + entries + |> Array.to_list |> List.mapi (fun index entry -> (index, entry)) + |> List.filter_map (fun (index, entry) -> + if index < start || index > finish then None + else + Some + (match entry.op with + | Equal line when is_blank_line line -> "" + | Equal line -> " " ^ line + | Delete line when is_blank_line line -> "-" + | Delete line -> "-" ^ line + | Insert line when is_blank_line line -> "+" + | Insert line -> "+" ^ line)) + in + String.concat "\n" (header :: lines) + + let collect_hunks entries context = + let len = Array.length entries in + let changes = + let rec loop index acc = + if index >= len then List.rev acc + else if is_change entries.(index) then loop (index + 1) (index :: acc) + else loop (index + 1) acc + in + loop 0 [] + in + let rec group acc = function + | [] -> List.rev acc + | change :: rest -> + let start = max 0 (change - context) in + let finish = ref (min (len - 1) (change + context)) in + let rec absorb = function + | next :: tail when next <= !finish + context + 1 -> + finish := min (len - 1) (next + context); + absorb tail + | remaining -> remaining + in + let remaining = absorb rest in + group ((start, !finish) :: acc) remaining + in + group [] changes + + let render ~path ~before ~after = + let entries = build_ops ~before ~after |> annotate in + let has_changes = + entries |> Array.exists is_change + in + if not has_changes then None + else + let hunks = collect_hunks entries 3 in + let body = + hunks |> List.map (fun (start, finish) -> render_hunk entries start finish) + in + Some + (String.concat "\n" + ([ + Printf.sprintf "--- a/%s" path; + Printf.sprintf "+++ b/%s" path; + ] + @ body)) +end + +let rewrite_file ~(config : config) path = + let prefer_switch_count = ref 0 in + let no_optional_some_count = ref 0 in + let rec expr mapper (expression : Parsetree.expression) = + match expression.pexp_desc with + | Pexp_apply {funct; args; partial; transformed_jsx} -> + let funct = expr mapper funct in + let args = + args + |> List.map (fun (label, arg) -> (label, expr mapper arg)) + |> List.map + (fun ((label, arg) : Asttypes.arg_label * Parsetree.expression) -> + match (label, arg.pexp_desc) with + | Asttypes.Optional {txt; loc}, Pexp_construct ({txt = Lident "Some"; _}, Some inner) + when config.rewrite.no_optional_some.enabled -> + incr no_optional_some_count; + (Asttypes.Labelled {txt; loc}, inner) + | _ -> (label, arg)) + in + { + expression with + pexp_desc = Pexp_apply {funct; args; partial; transformed_jsx}; + } + | Pexp_ifthenelse _ when should_rewrite_if ~config expression -> + let rec collect branches (current_expr : Parsetree.expression) = + match current_expr.pexp_desc with + | Pexp_ifthenelse (condition, then_expr, Some else_expr) + when should_rewrite_if ~config current_expr + && not + (branches <> [] + && has_non_ternary_attributes current_expr.pexp_attributes) + -> + collect ((condition, then_expr) :: branches) else_expr + | Pexp_ifthenelse (condition, then_expr, None) + when should_rewrite_if ~config current_expr + && not + (branches <> [] + && has_non_ternary_attributes current_expr.pexp_attributes) + -> + (List.rev ((condition, then_expr) :: branches), None) + | _ -> (List.rev branches, Some current_expr) + in + let branches, fallback = collect [] expression in + let branches = + branches + |> List.map (fun (condition, then_expr) -> + (expr mapper condition, expr mapper then_expr)) + in + let fallback = + match fallback with + | Some fallback -> expr mapper fallback + | None -> unit_expression ~loc:expression.pexp_loc + in + let attrs = filter_ternary_attributes expression.pexp_attributes in + incr prefer_switch_count; + (match branches with + | [condition, then_expr] -> + make_boolean_switch ~loc:expression.pexp_loc ~attrs condition then_expr + fallback + | _ -> + make_guard_switch ~loc:expression.pexp_loc ~attrs branches fallback) + | _ -> Ast_mapper.default_mapper.expr mapper expression + in + let mapper = {Ast_mapper.default_mapper with expr} in + let finalize ~source ~contents = + let applied_rules = + applied_rules_of_counts + { + prefer_switch = !prefer_switch_count; + no_optional_some = !no_optional_some_count; + } + in + if applied_rules = [] then + Ok {changed = false; source_contents = source; contents = source; applied_rules} + else + match verify_rewritten_source ~path ~contents with + | Error _ as error -> error + | Ok () -> + Ok + { + changed = contents <> source; + source_contents = source; + contents; + applied_rules; + } + in + if Filename.check_suffix path ".resi" then + let {Res_driver.parsetree; comments; source; _} = + Res_driver.parsing_engine.parse_interface ~for_printer:true ~filename:path + in + let signature = mapper.signature mapper parsetree in + let contents = Res_printer.print_interface signature ~comments in + finalize ~source ~contents + else if Filename.check_suffix path ".res" then + let {Res_driver.parsetree; comments; source; _} = + Res_driver.parsing_engine.parse_implementation ~for_printer:true + ~filename:path + in + let structure = mapper.structure mapper parsetree in + let contents = + Res_printer.print_implementation ~width:Res_printer.default_print_width + structure ~comments + in + finalize ~source ~contents + else + Error + (Printf.sprintf + "File extension not supported. This command accepts .res and .resi \ + files") + +let mode_name = function + | Write -> "write" + | Diff -> "diff" + +let status_for_mode mode changed = + match (mode, changed) with + | Write, true -> "rewritten" + | Diff, true -> "preview" + | _, false -> "unchanged" + +let stringify_applied_rule_json (applied_rule : applied_rule) = + Lint_support.Json.stringify_compact_object + [ + ("rule", Some (Protocol.wrapInQuotes applied_rule.rule)); + ("count", Some (string_of_int applied_rule.count)); + ("note", Some (Protocol.wrapInQuotes applied_rule.note)); + ] + +let stringify_applied_rules_json applied_rules = + Protocol.array (applied_rules |> List.map stringify_applied_rule_json) + +let stringify_applied_rules_text applied_rules = + applied_rules + |> List.map (fun applied_rule -> + Printf.sprintf "- %s(%d): %s" applied_rule.rule applied_rule.count + applied_rule.note) + +let count_unchanged_files results = + results + |> List.fold_left + (fun count (result : file_result) -> if result.changed then count else count + 1) + 0 + +let summarize_applied_rules results = + let totals = Hashtbl.create 8 in + results + |> List.iter (fun (result : file_result) -> + result.applied_rules + |> List.iter (fun (applied_rule : applied_rule) -> + let total = + Hashtbl.find_opt totals applied_rule.rule |> Option.value ~default:0 + in + Hashtbl.replace totals applied_rule.rule (total + applied_rule.count))); + rewrite_rule_infos + |> List.filter_map (fun (rule_info : rule_info) -> + match Hashtbl.find_opt totals rule_info.rule with + | Some count when count > 0 -> + Some + { + rule = rule_info.rule; + count; + note = rewrite_note_for_rule rule_info.rule; + } + | _ -> None) + +let summary_label_for_mode = function + | Write -> "rewritten" + | Diff -> "previewed" + +let stringify_summary_text ~mode results = + let changed_files = + results + |> List.fold_left + (fun count (result : file_result) -> if result.changed then count + 1 else count) + 0 + in + let unchanged_files = count_unchanged_files results in + let rule_totals = summarize_applied_rules results in + let rule_summary = + if rule_totals = [] then "" + else + ", rules: " + ^ String.concat ", " + (rule_totals + |> List.map (fun (applied_rule : applied_rule) -> + Printf.sprintf "%s(%d)" applied_rule.rule applied_rule.count)) + in + Printf.sprintf "summary: %s %d files, unchanged %d%s" + (summary_label_for_mode mode) changed_files unchanged_files rule_summary + +let stringify_text_file_result ~mode ~display_base (result : file_result) = + let path = Lint_support.Path.display ~base:display_base result.abs_path in + let lines = + [ + Printf.sprintf "path: %s" path; + Printf.sprintf "status: %s" (status_for_mode mode result.changed); + ] + in + let lines = + if result.applied_rules = [] then lines + else lines @ ("rules:" :: stringify_applied_rules_text result.applied_rules) + in + match result.diff with + | None -> String.concat "\n" lines + | Some diff -> + String.concat "\n" (lines @ ["diff:"; "```diff"; diff; "```"]) + +let stringify_json_file_result ~mode ~display_base (result : file_result) = + let path = Lint_support.Path.display ~base:display_base result.abs_path in + Lint_support.Json.stringify_compact_object + [ + ("path", Some (Protocol.wrapInQuotes path)); + ("mode", Some (Protocol.wrapInQuotes (mode_name mode))); + ("changed", Some (string_of_bool result.changed)); + ("applied", Some (stringify_applied_rules_json result.applied_rules)); + ("diff", Protocol.optWrapInQuotes result.diff); + ("rewritten", Protocol.optWrapInQuotes result.rewritten_contents); + ] + +let render_results ~mode ~json ~display_base results = + if json then + "[" + ^ String.concat "," + (results + |> List.map (stringify_json_file_result ~mode ~display_base)) + ^ "]" + else + let sections = + results |> List.map (stringify_text_file_result ~mode ~display_base) + in + String.concat "\n\n" (sections @ [stringify_summary_text ~mode results]) + +let write_contents path contents = + let oc = open_out path in + Printf.fprintf oc "%s" contents; + close_out oc + +let run ?config_path ?(json = false) ?(mode = Write) target = + let target = + if Filename.is_relative target then Filename.concat (Sys.getcwd ()) target + else target + in + if not (Files.exists target) then + Error ("error: no such file or directory: " ^ target) + else if + (not (Lint_support.Path.is_directory target)) + && not (FindFiles.isSourceFile target) + then + Error + "File extension not supported. This command accepts .res and .resi \ + files" + else + let target = Unix.realpath target in + match Lint_config.load ?config_path target with + | Error _ as error -> error + | Ok config -> + let files = collect_files target in + let display_base = display_base target files in + let process_one path = + match rewrite_file ~config path with + | Error _ as error -> error + | Ok {changed; source_contents; contents; applied_rules} -> + (match mode with + | Write when changed -> write_contents path contents + | Write | Diff -> ()); + let rel_path = Lint_support.Path.display ~base:display_base path in + let diff = + match (mode, changed) with + | Diff, true -> + Diff.render ~path:rel_path ~before:source_contents ~after:contents + | Write, _ | Diff, false -> None + in + let rewritten_contents = + match (mode, changed) with + | Diff, true -> Some contents + | Write, _ | Diff, false -> None + in + Ok {abs_path = path; changed; applied_rules; diff; rewritten_contents} + in + let rec collect acc = function + | [] -> Ok (List.rev acc) + | path :: rest -> ( + match process_one path with + | Error _ as error -> error + | Ok result -> collect (result :: acc) rest) + in + match collect [] files with + | Error _ as error -> error + | Ok results -> + Ok + { + output = render_results ~mode ~json ~display_base results; + changed_files = + results + |> List.fold_left + (fun count (result : file_result) -> + if result.changed then count + 1 else count) + 0; + } diff --git a/tools/src/tools.ml b/tools/src/tools.ml index 7aa91063acd..65e545137af 100644 --- a/tools/src/tools.ml +++ b/tools/src/tools.ml @@ -1,7 +1,6 @@ open Analysis module ActiveRules = Active_rules - module StringSet = Set.Make (String) type fieldDoc = { @@ -1297,5 +1296,6 @@ end module Migrate = Migrate module Lint = Lint +module Rewrite = Rewrite module Show = Show module Find_references = Find_references From e7597238b498e2fca6b0516f31ac54381ad31a99 Mon Sep 17 00:00:00 2001 From: Gabriel Nordeborn Date: Sun, 19 Apr 2026 15:39:10 +0200 Subject: [PATCH 07/17] reuse shared lint support helpers --- tools/src/lint_analysis.ml | 22 ++-------------------- tools/src/lint_output.ml | 32 +++++--------------------------- 2 files changed, 7 insertions(+), 47 deletions(-) diff --git a/tools/src/lint_analysis.ml b/tools/src/lint_analysis.ml index 07eefd01ce2..5d6edf4d7f0 100644 --- a/tools/src/lint_analysis.ml +++ b/tools/src/lint_analysis.ml @@ -18,8 +18,6 @@ let match_forbidden_path items path = let path_key path = String.concat "." path -let placeholder_module_path = "place holder" - let package_for_path path = let uri = Uri.fromPath path in Packages.getPackage ~uri @@ -42,22 +40,6 @@ let rec drop_path count path = | [] -> [] | _ :: tail -> drop_path (count - 1) tail -let resolve_module_env ~package path = - match path with - | [] -> None - | root_module :: nested_path -> ( - match ProcessCmt.fileForModule root_module ~package with - | None -> None - | Some file -> - let env = QueryEnv.fromFile file in - match nested_path with - | [] -> Some env - | _ -> - ResolvePath.resolvePath ~env - ~path:(nested_path @ [placeholder_module_path]) - ~package - |> Option.map fst) - let resolve_exported_path ~env ~package path = let resolve tip find_declared = match References.exportedForTip ~env ~path ~package ~tip with @@ -86,7 +68,7 @@ let resolve_forbidden_reference_items ~target_path items = match Hashtbl.find_opt resolved_module_cache (path_key path) with | Some resolved -> resolved | None -> - let resolved = resolve_module_env ~package path in + let resolved = Lint_support.SymbolPath.resolve_module_env ~package path in Hashtbl.add resolved_module_cache (path_key path) resolved; resolved in @@ -381,7 +363,7 @@ let display_base target_path files = else Filename.dirname target_path let dedupe_findings findings = - let same_signature left right = + let same_signature (left : raw_finding) (right : raw_finding) = left.rule = right.rule && left.abs_path = right.abs_path && left.symbol = right.symbol diff --git a/tools/src/lint_output.ml b/tools/src/lint_output.ml index 0781fc9277c..ab1106f49bd 100644 --- a/tools/src/lint_output.ml +++ b/tools/src/lint_output.ml @@ -1,33 +1,11 @@ open Analysis open Lint_shared -let get_source source_cache path = - match Hashtbl.find_opt source_cache path with - | Some source -> source - | None -> - let source = Files.readFile path in - Hashtbl.add source_cache path source; - source - -let trim_trailing_newlines value = - let rec loop last = - if last < 0 then "" - else if value.[last] = '\n' || value.[last] = '\r' then loop (last - 1) - else String.sub value 0 (last + 1) - in - loop (String.length value - 1) - let snippet_of_raw ~source_cache (raw_finding : raw_finding) = - match get_source source_cache raw_finding.abs_path with - | None -> None - | Some source -> - Code_frame.print ~highlight_style:Underlined ~context_lines_before:2 - ~context_lines_after:1 ~skip_blank_context:true - ~is_warning:(raw_finding.severity = SeverityWarning) - ~src:source ~start_pos:raw_finding.loc.loc_start - ~end_pos:raw_finding.loc.loc_end - |> trim_trailing_newlines - |> fun snippet -> Some ("```text\n" ^ snippet ^ "\n```") + Lint_support.Snippet.of_loc ~source_cache ~path:raw_finding.abs_path + ~loc:raw_finding.loc + ~is_warning:(raw_finding.severity = SeverityWarning) + () let compact_finding_fields (finding : finding) = [ @@ -62,7 +40,7 @@ let stringify_text_finding ~source_cache ~display_base | Some snippet -> String.concat "\n" (lines @ ["snippet:"; snippet]) let stringify_text_findings ~display_base findings = - let source_cache = Hashtbl.create 16 in + let source_cache = Lint_support.Snippet.create_cache () in findings |> List.map (stringify_text_finding ~source_cache ~display_base) |> String.concat "\n\n" From ea503bb98a1dd21862153a6a4cab8afedcece11a Mon Sep 17 00:00:00 2001 From: Gabriel Nordeborn Date: Sun, 19 Apr 2026 15:42:32 +0200 Subject: [PATCH 08/17] update doc --- docs/rescript_ai.md | 175 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 145 insertions(+), 30 deletions(-) diff --git a/docs/rescript_ai.md b/docs/rescript_ai.md index 34f094ab392..36d8cb420a2 100644 --- a/docs/rescript_ai.md +++ b/docs/rescript_ai.md @@ -21,7 +21,9 @@ Aggressive source normalization for agents should be a separate command rather t ## First Feature: Lint -The rest of this document focuses on lint as the first implementation target. +The rest of this document starts from lint as the first implementation target, +but later sections also cover rewrite and semantic lookup commands that now +belong in the same tool surface. ## Command Surface @@ -29,7 +31,11 @@ Recommended first shape: ```sh rescript-tools lint [--config ] [--json] -rescript-tools rewrite [--config ] [--json] +rescript-tools rewrite [--config ] [--diff] [--json] +rescript-tools active-rules [--config ] [--json] +rescript-tools show [--kind ] [--context ] [--comments ] +rescript-tools find-references [--kind ] [--context ] +rescript-tools find-references --file --line --col ``` Notes: @@ -43,6 +49,12 @@ Notes: - `rewrite` is separate from `lint` - `rewrite` is allowed to be more aggressive because it is explicitly agent-oriented - lint reports style/semantic problems; rewrite canonicalizes source into a narrower normal form +- `rewrite --diff` should preview the rewritten diff without modifying files +- `rewrite` should emit a short summary of what changed after a write pass +- `active-rules` should list lint and rewrite rules, whether they are active, and what they do +- `show` should expose hover-style semantic lookup by symbol path instead of source position +- `show --comments omit` should make it easy to get a tighter, agent-oriented output +- `find-references` should support both symbol-path and source-location queries ## Recommended Placement @@ -149,17 +161,19 @@ Example shape: ```json { - "rules": { - "forbidden-reference": { - "severity": "error", - "items": [ - "RescriptCore", - "Belt", - "Belt.Array.forEach" - ] - }, - "single-use-function": { - "severity": "warning" + "lint": { + "rules": { + "forbidden-reference": { + "severity": "error", + "items": [ + "RescriptCore", + "Belt", + "Belt.Array.forEach" + ] + }, + "single-use-function": { + "severity": "warning" + } } } } @@ -215,6 +229,65 @@ Likely exclusions for V1: Longer term, this can grow into a reanalyze-style map/merge analysis. +## Candidate Lint Rule Ideas + +This section is intentionally a scratchpad for future rules. + +### React component file shape + +- React component files must define an interface for the component props +- React component files should only export the React component itself +- disallow plain functions that return JSX; require them to be defined as React components instead +- goal: keep file shape predictable and preserve HMR-friendly module boundaries + +### Size limits + +- file length limits +- function length limits +- max JSX size/length limits + +### Naming conventions + +- configurable naming rules +- examples: + - `camelCase` vs `snake_case` + - likely separate handling for values, types, modules, files, and props + +### Regex-based validation + +- configurable regex validation for names or other text-shaped surfaces +- useful when a team wants project-specific constraints without adding a bespoke rule + +### String literal normalization + +- enforce regular string literals instead of template strings when interpolation or other template-only behavior is not needed + +### Builtin type spellings + +- enforce builtin type spellings where available +- example: prefer `dict<>` over `Dict.t<>` + +### Dict normalization + +- prefer dict spread over helper-based concat/merge patterns when the result is equivalent +- prefer dict literal syntax when constructing dicts in cases where a literal is a clearer canonical form + +### Alias avoidance + +- disallow aliases in general when the alias only shortens an existing qualified path +- prefer the fully qualified reference path instead + +### Custom error messages + +- allow custom error messages per rule or per violation pattern +- useful when a team wants lint output to teach the preferred local convention, not just report failure + +### JSX element rules + +- require certain props for specific JSX elements +- example: enforce required props per element type +- disallow rendering `` directly and require a designated `Link` component instead + ## Separate Command: Agent Rewrite Aggressive source normalization for agents should be a separate command rather than part of lint. @@ -222,7 +295,7 @@ Aggressive source normalization for agents should be a separate command rather t Recommended first shape: ```sh -rescript-tools rewrite [--config ] [--json] +rescript-tools rewrite [--config ] [--diff] [--json] ``` Goal: @@ -234,6 +307,33 @@ Goal: This is intentionally not just lint autofix. It is an agent-oriented normalization pass. +Write mode should still emit a short summary after applying rewrites so an +agent can quickly tell which rules fired and how much source changed. + +Lint and rewrite config should each live under their own namespace in `.rescript-lint.json`, for example: + +```json +{ + "lint": { + "rules": { + "forbidden-reference": { + "severity": "error", + "items": ["Belt.Array.forEach", "Belt.Array.map", "Js.Json.t"] + }, + "single-use-function": { + "severity": "warning" + } + } + }, + "rewrite": { + "rules": { + "prefer-switch": {"enabled": true, "if": true, "ternary": true}, + "no-optional-some": {"enabled": true} + } + } +} +``` + ### Rewrite model - rewrite to a fixed point until the file stops changing @@ -293,7 +393,7 @@ Why: Implementation notes: - this should be an AST-based rewrite rule -- the rewrite is straightforward: `?Some(expr)` -> `?expr` +- the rewrite is straightforward: `~label=?Some(expr)` -> `~label=expr` #### Other good candidates @@ -378,6 +478,22 @@ This section is intentionally a scratchpad. Add ideas here freely before they ar - docs - module path - source location +- type-at for a symbol or source location +- implementation jump + - for example `.resi` to `.res` +- signature help for agents + - labels + - arity + - current argument position +- document symbols / file outline +- workspace symbols / symbol search + - values + - types + - modules +- deterministic completion surfaces + - module members + - record fields + - variant constructors - search symbols/modules by semantic identity rather than raw grep - find implementations / exported values / local bindings by kind - rename preview for agents @@ -401,21 +517,20 @@ This section is intentionally a scratchpad. Add ideas here freely before they ar - "show all externals in this package" - "show all uses of Belt in changed files" -## Suggested Implementation Order - -1. Add `lint` command and help text in `tools/bin/main.ml` -2. Create a new `Tools.Lint` module in `tools/src/` -3. Define config parsing and finding/output types -4. Implement AST lane plumbing -5. Implement typed lane plumbing on top of `analysis/src/Cmt.ml` -6. Implement `forbidden-reference` -7. Implement local `single-use-function` -8. Add `--git` line-range filtering -9. Add tests for file mode and project mode -10. Add `rewrite` command and a separate `Tools.Rewrite` module -11. Implement rewrite verification and fixed-point pass ordering -12. Add aggressive canonicalization rules like `if`/ternary -> `switch` -13. Add optional-arg normalization like `?Some(expr)` -> `?expr` +## Remaining Implementation Priorities + +Much of the initial bootstrapping work described earlier in this document is +now done. The current command surface already includes `lint`, `rewrite`, +`active-rules`, `show`, and `find-references`, along with namespaced +`lint`/`rewrite` config and the first rewrite rules. + +Near-term remaining work is mostly about hardening and narrowing the scope of +future additions: + +1. Add `--git` line-range filtering +2. Harden rewrite verification with fixed-point pass ordering +3. Add type-aware rewrite validation after rewriting +4. Keep expanding semantic lookup/editing commands only where they prove useful ## Testing Plan From bf307ec4600ede96cc66b1bbfe4c288fc24958df Mon Sep 17 00:00:00 2001 From: Gabriel Nordeborn Date: Sun, 19 Apr 2026 19:04:09 +0200 Subject: [PATCH 09/17] add configurable lint messages and alias-avoidance Signed-off-by: Gabriel Nordeborn --- docs/rescript_ai.md | 38 +- tests/tools_tests/.rescript-lint.json | 40 +- .../expected/AliasAvoidance.res.lint.expected | 37 ++ .../AliasAvoidance.res.lint.json.expected | 1 + .../AliasAvoidance.resi.lint.expected | 24 ++ .../AliasAvoidance.resi.lint.json.expected | 1 + .../ForbiddenExplicit.res.lint.expected | 2 +- .../ForbiddenExplicit.res.lint.json.expected | 2 +- .../ForbiddenModule.res.lint.expected | 11 + .../ForbiddenModule.res.lint.json.expected | 1 + .../expected/ForbiddenOpen.res.lint.expected | 2 +- .../ForbiddenOpen.res.lint.json.expected | 2 +- .../expected/ForbiddenType.res.lint.expected | 16 +- .../ForbiddenType.res.lint.json.expected | 2 +- .../src/expected/SingleUse.res.lint.expected | 2 +- .../expected/SingleUse.res.lint.json.expected | 2 +- .../src/expected/active-rules.expected | 37 +- .../src/expected/active-rules.json.expected | 2 +- .../src/expected/lint-root.expected | 97 ++++- .../src/expected/lint-root.json.expected | 2 +- tests/tools_tests/src/lint/AliasAvoidance.res | 11 + .../tools_tests/src/lint/AliasAvoidance.resi | 6 + .../tools_tests/src/lint/ForbiddenModule.res | 1 + tests/tools_tests/test.sh | 2 +- tools/src/active_rules.ml | 17 +- tools/src/lint_analysis.ml | 383 ++++++++++++++---- tools/src/lint_config.ml | 208 ++++++++-- tools/src/lint_shared.ml | 263 +++++++++--- 28 files changed, 1004 insertions(+), 208 deletions(-) create mode 100644 tests/tools_tests/src/expected/AliasAvoidance.res.lint.expected create mode 100644 tests/tools_tests/src/expected/AliasAvoidance.res.lint.json.expected create mode 100644 tests/tools_tests/src/expected/AliasAvoidance.resi.lint.expected create mode 100644 tests/tools_tests/src/expected/AliasAvoidance.resi.lint.json.expected create mode 100644 tests/tools_tests/src/expected/ForbiddenModule.res.lint.expected create mode 100644 tests/tools_tests/src/expected/ForbiddenModule.res.lint.json.expected create mode 100644 tests/tools_tests/src/lint/AliasAvoidance.res create mode 100644 tests/tools_tests/src/lint/AliasAvoidance.resi create mode 100644 tests/tools_tests/src/lint/ForbiddenModule.res diff --git a/docs/rescript_ai.md b/docs/rescript_ai.md index 36d8cb420a2..cde94592280 100644 --- a/docs/rescript_ai.md +++ b/docs/rescript_ai.md @@ -163,14 +163,26 @@ Example shape: { "lint": { "rules": { - "forbidden-reference": { - "severity": "error", - "items": [ - "RescriptCore", - "Belt", - "Belt.Array.forEach" - ] - }, + "forbidden-reference": [ + { + "severity": "error", + "message": "Do not use Belt.Array helpers here.", + "items": [ + {"kind": "module", "path": "Belt.Array"}, + {"kind": "value", "path": "Belt.Array.forEach"} + ] + }, + { + "severity": "warning", + "items": [ + { + "kind": "type", + "path": "Js.Json.t", + "message": "Avoid Js.Json.t here." + } + ] + } + ], "single-use-function": { "severity": "warning" } @@ -179,6 +191,10 @@ Example shape: } ``` +`forbidden-reference` accepts either one rule object or an array of rule objects. +Each `items` entry must be an object with `kind` (`module`, `value`, or `type`) +and `path`; `message` is optional at both the rule and item level. + ## Lint V1 Rules ### Forbidden references @@ -318,7 +334,11 @@ Lint and rewrite config should each live under their own namespace in `.rescript "rules": { "forbidden-reference": { "severity": "error", - "items": ["Belt.Array.forEach", "Belt.Array.map", "Js.Json.t"] + "items": [ + {"kind": "value", "path": "Belt.Array.forEach"}, + {"kind": "value", "path": "Belt.Array.map"}, + {"kind": "type", "path": "Js.Json.t"} + ] }, "single-use-function": { "severity": "warning" diff --git a/tests/tools_tests/.rescript-lint.json b/tests/tools_tests/.rescript-lint.json index 39d5cda83ee..6fb39b0af04 100644 --- a/tests/tools_tests/.rescript-lint.json +++ b/tests/tools_tests/.rescript-lint.json @@ -1,11 +1,43 @@ { "lint": { "rules": { - "forbidden-reference": { - "severity": "error", - "items": ["Belt.Array.forEach", "Belt.Array.map", "Js.Json.t"] - }, + "forbidden-reference": [ + { + "severity": "error", + "items": [ + { + "kind": "module", + "path": "Belt.Array", + "message": "Avoid Belt.Array module references here." + }, + { + "kind": "value", + "path": "Belt.Array.forEach" + }, + { + "kind": "value", + "path": "Belt.Array.map", + "message": "Prefer Array.map directly." + } + ], + "message": "Do not use Belt.Array helpers here. Prefer Stdlib/Array directly." + }, + { + "severity": "warning", + "items": [ + { + "kind": "type", + "path": "Js.Json.t", + "message": "Avoid Js.Json.t here." + } + ] + } + ], "single-use-function": { + "severity": "warning", + "message": "Inline this helper unless it is a meaningful reusable abstraction." + }, + "alias-avoidance": { "severity": "warning" } } diff --git a/tests/tools_tests/src/expected/AliasAvoidance.res.lint.expected b/tests/tools_tests/src/expected/AliasAvoidance.res.lint.expected new file mode 100644 index 00000000000..a38e91e32ef --- /dev/null +++ b/tests/tools_tests/src/expected/AliasAvoidance.res.lint.expected @@ -0,0 +1,37 @@ +severity: warning +rule: alias-avoidance +path: src/lint/AliasAvoidance.res +range: 1:8-1:21 +message: Use the fully qualified reference directly instead of creating a local alias +symbol: ShowFixture.Nested +snippet: +```text +> 1 | module AliasGreeting = ShowFixture.Nested + | ^^^^^^^^^^^^^ +``` + +severity: warning +rule: alias-avoidance +path: src/lint/AliasAvoidance.res +range: 3:6-3:15 +message: Use the fully qualified reference directly instead of creating a local alias +symbol: ShowFixture.item +snippet: +```text + 1 | module AliasGreeting = ShowFixture.Nested +> 3 | type itemAlias = ShowFixture.item + | ^^^^^^^^^ +``` + +severity: warning +rule: alias-avoidance +path: src/lint/AliasAvoidance.res +range: 5:5-5:8 +message: Use the fully qualified reference directly instead of creating a local alias +symbol: String.length +snippet: +```text + 3 | type itemAlias = ShowFixture.item +> 5 | let len = String.length + | ^^^ +``` diff --git a/tests/tools_tests/src/expected/AliasAvoidance.res.lint.json.expected b/tests/tools_tests/src/expected/AliasAvoidance.res.lint.json.expected new file mode 100644 index 00000000000..708ce0caf65 --- /dev/null +++ b/tests/tools_tests/src/expected/AliasAvoidance.res.lint.json.expected @@ -0,0 +1 @@ +[{"rule":"alias-avoidance","path":"src/lint/AliasAvoidance.res","range":[0,7,0,20],"severity":"warning","message":"Use the fully qualified reference directly instead of creating a local alias","symbol":"ShowFixture.Nested"},{"rule":"alias-avoidance","path":"src/lint/AliasAvoidance.res","range":[2,5,2,14],"severity":"warning","message":"Use the fully qualified reference directly instead of creating a local alias","symbol":"ShowFixture.item"},{"rule":"alias-avoidance","path":"src/lint/AliasAvoidance.res","range":[4,4,4,7],"severity":"warning","message":"Use the fully qualified reference directly instead of creating a local alias","symbol":"String.length"}] diff --git a/tests/tools_tests/src/expected/AliasAvoidance.resi.lint.expected b/tests/tools_tests/src/expected/AliasAvoidance.resi.lint.expected new file mode 100644 index 00000000000..8088f32fc2c --- /dev/null +++ b/tests/tools_tests/src/expected/AliasAvoidance.resi.lint.expected @@ -0,0 +1,24 @@ +severity: warning +rule: alias-avoidance +path: src/lint/AliasAvoidance.resi +range: 1:8-1:21 +message: Use the fully qualified reference directly instead of creating a local alias +symbol: ShowFixture.Nested +snippet: +```text +> 1 | module AliasGreeting = ShowFixture.Nested + | ^^^^^^^^^^^^^ +``` + +severity: warning +rule: alias-avoidance +path: src/lint/AliasAvoidance.resi +range: 3:6-3:15 +message: Use the fully qualified reference directly instead of creating a local alias +symbol: ShowFixture.item +snippet: +```text + 1 | module AliasGreeting = ShowFixture.Nested +> 3 | type itemAlias = ShowFixture.item + | ^^^^^^^^^ +``` diff --git a/tests/tools_tests/src/expected/AliasAvoidance.resi.lint.json.expected b/tests/tools_tests/src/expected/AliasAvoidance.resi.lint.json.expected new file mode 100644 index 00000000000..8c930df9197 --- /dev/null +++ b/tests/tools_tests/src/expected/AliasAvoidance.resi.lint.json.expected @@ -0,0 +1 @@ +[{"rule":"alias-avoidance","path":"src/lint/AliasAvoidance.resi","range":[0,7,0,20],"severity":"warning","message":"Use the fully qualified reference directly instead of creating a local alias","symbol":"ShowFixture.Nested"},{"rule":"alias-avoidance","path":"src/lint/AliasAvoidance.resi","range":[2,5,2,14],"severity":"warning","message":"Use the fully qualified reference directly instead of creating a local alias","symbol":"ShowFixture.item"}] diff --git a/tests/tools_tests/src/expected/ForbiddenExplicit.res.lint.expected b/tests/tools_tests/src/expected/ForbiddenExplicit.res.lint.expected index 1dc6f31c7ec..8c39204722a 100644 --- a/tests/tools_tests/src/expected/ForbiddenExplicit.res.lint.expected +++ b/tests/tools_tests/src/expected/ForbiddenExplicit.res.lint.expected @@ -2,7 +2,7 @@ severity: error rule: forbidden-reference path: src/lint/ForbiddenExplicit.res range: 1:28-1:35 -message: Forbidden reference +message: Do not use Belt.Array helpers here. Prefer Stdlib/Array directly. symbol: Belt_Array.forEach snippet: ```text diff --git a/tests/tools_tests/src/expected/ForbiddenExplicit.res.lint.json.expected b/tests/tools_tests/src/expected/ForbiddenExplicit.res.lint.json.expected index 6eab87f9c8b..a3dffb05145 100644 --- a/tests/tools_tests/src/expected/ForbiddenExplicit.res.lint.json.expected +++ b/tests/tools_tests/src/expected/ForbiddenExplicit.res.lint.json.expected @@ -1 +1 @@ -[{"rule":"forbidden-reference","path":"src/lint/ForbiddenExplicit.res","range":[0,27,0,34],"severity":"error","message":"Forbidden reference","symbol":"Belt_Array.forEach"}] +[{"rule":"forbidden-reference","path":"src/lint/ForbiddenExplicit.res","range":[0,27,0,34],"severity":"error","message":"Do not use Belt.Array helpers here. Prefer Stdlib/Array directly.","symbol":"Belt_Array.forEach"}] diff --git a/tests/tools_tests/src/expected/ForbiddenModule.res.lint.expected b/tests/tools_tests/src/expected/ForbiddenModule.res.lint.expected new file mode 100644 index 00000000000..f280914a6fd --- /dev/null +++ b/tests/tools_tests/src/expected/ForbiddenModule.res.lint.expected @@ -0,0 +1,11 @@ +severity: error +rule: forbidden-reference +path: src/lint/ForbiddenModule.res +range: 1:23-1:29 +message: Avoid Belt.Array module references here. +symbol: Belt_Array.length +snippet: +```text +> 1 | let size = Belt.Array.length([1, 2]) + | ^^^^^^ +``` diff --git a/tests/tools_tests/src/expected/ForbiddenModule.res.lint.json.expected b/tests/tools_tests/src/expected/ForbiddenModule.res.lint.json.expected new file mode 100644 index 00000000000..70936e5dc58 --- /dev/null +++ b/tests/tools_tests/src/expected/ForbiddenModule.res.lint.json.expected @@ -0,0 +1 @@ +[{"rule":"forbidden-reference","path":"src/lint/ForbiddenModule.res","range":[0,22,0,28],"severity":"error","message":"Avoid Belt.Array module references here.","symbol":"Belt_Array.length"}] diff --git a/tests/tools_tests/src/expected/ForbiddenOpen.res.lint.expected b/tests/tools_tests/src/expected/ForbiddenOpen.res.lint.expected index 97ed9c66e4b..ea63f55b63a 100644 --- a/tests/tools_tests/src/expected/ForbiddenOpen.res.lint.expected +++ b/tests/tools_tests/src/expected/ForbiddenOpen.res.lint.expected @@ -2,7 +2,7 @@ severity: error rule: forbidden-reference path: src/lint/ForbiddenOpen.res range: 3:17-3:20 -message: Forbidden reference +message: Prefer Array.map directly. symbol: Belt_Array.map snippet: ```text diff --git a/tests/tools_tests/src/expected/ForbiddenOpen.res.lint.json.expected b/tests/tools_tests/src/expected/ForbiddenOpen.res.lint.json.expected index 07bab16c0d2..ca23d9aff2d 100644 --- a/tests/tools_tests/src/expected/ForbiddenOpen.res.lint.json.expected +++ b/tests/tools_tests/src/expected/ForbiddenOpen.res.lint.json.expected @@ -1 +1 @@ -[{"rule":"forbidden-reference","path":"src/lint/ForbiddenOpen.res","range":[2,16,2,19],"severity":"error","message":"Forbidden reference","symbol":"Belt_Array.map"}] +[{"rule":"forbidden-reference","path":"src/lint/ForbiddenOpen.res","range":[2,16,2,19],"severity":"error","message":"Prefer Array.map directly.","symbol":"Belt_Array.map"}] diff --git a/tests/tools_tests/src/expected/ForbiddenType.res.lint.expected b/tests/tools_tests/src/expected/ForbiddenType.res.lint.expected index 499c2a444b0..8ae643c82f9 100644 --- a/tests/tools_tests/src/expected/ForbiddenType.res.lint.expected +++ b/tests/tools_tests/src/expected/ForbiddenType.res.lint.expected @@ -1,8 +1,20 @@ -severity: error +severity: warning +rule: alias-avoidance +path: src/lint/ForbiddenType.res +range: 1:6-1:10 +message: Use the fully qualified reference directly instead of creating a local alias +symbol: Js.Json.t +snippet: +```text +> 1 | type json = Js.Json.t + | ^^^^ +``` + +severity: warning rule: forbidden-reference path: src/lint/ForbiddenType.res range: 1:21-1:22 -message: Forbidden reference +message: Avoid Js.Json.t here. symbol: Js_json.t snippet: ```text diff --git a/tests/tools_tests/src/expected/ForbiddenType.res.lint.json.expected b/tests/tools_tests/src/expected/ForbiddenType.res.lint.json.expected index 7b959a3ca37..35c0ee76caa 100644 --- a/tests/tools_tests/src/expected/ForbiddenType.res.lint.json.expected +++ b/tests/tools_tests/src/expected/ForbiddenType.res.lint.json.expected @@ -1 +1 @@ -[{"rule":"forbidden-reference","path":"src/lint/ForbiddenType.res","range":[0,20,0,21],"severity":"error","message":"Forbidden reference","symbol":"Js_json.t"}] +[{"rule":"alias-avoidance","path":"src/lint/ForbiddenType.res","range":[0,5,0,9],"severity":"warning","message":"Use the fully qualified reference directly instead of creating a local alias","symbol":"Js.Json.t"},{"rule":"forbidden-reference","path":"src/lint/ForbiddenType.res","range":[0,20,0,21],"severity":"warning","message":"Avoid Js.Json.t here.","symbol":"Js_json.t"}] diff --git a/tests/tools_tests/src/expected/SingleUse.res.lint.expected b/tests/tools_tests/src/expected/SingleUse.res.lint.expected index 5ae4dfa113c..c68bb195e11 100644 --- a/tests/tools_tests/src/expected/SingleUse.res.lint.expected +++ b/tests/tools_tests/src/expected/SingleUse.res.lint.expected @@ -2,7 +2,7 @@ severity: warning rule: single-use-function path: src/lint/SingleUse.res range: 2:7-2:13 -message: Local function is only used once +message: Inline this helper unless it is a meaningful reusable abstraction. symbol: helper snippet: ```text diff --git a/tests/tools_tests/src/expected/SingleUse.res.lint.json.expected b/tests/tools_tests/src/expected/SingleUse.res.lint.json.expected index 450cc35ebda..cb1236a36e9 100644 --- a/tests/tools_tests/src/expected/SingleUse.res.lint.json.expected +++ b/tests/tools_tests/src/expected/SingleUse.res.lint.json.expected @@ -1 +1 @@ -[{"rule":"single-use-function","path":"src/lint/SingleUse.res","range":[1,6,1,12],"severity":"warning","message":"Local function is only used once","symbol":"helper"}] +[{"rule":"single-use-function","path":"src/lint/SingleUse.res","range":[1,6,1,12],"severity":"warning","message":"Inline this helper unless it is a meaningful reusable abstraction.","symbol":"helper"}] diff --git a/tests/tools_tests/src/expected/active-rules.expected b/tests/tools_tests/src/expected/active-rules.expected index 2d61bd4545b..41f2aa3d206 100644 --- a/tests/tools_tests/src/expected/active-rules.expected +++ b/tests/tools_tests/src/expected/active-rules.expected @@ -1,12 +1,34 @@ namespace: lint rule: forbidden-reference +instance: 1 active: true summary: Report references to configured modules, values, and types. details: Uses typed references when available so opened modules and aliases resolve to the real symbol path before matching. settings: - enabled: true - severity: error -- items: Belt.Array.forEach, Belt.Array.map, Js.Json.t +- message: Do not use Belt.Array helpers here. Prefer Stdlib/Array directly. +- item[1].kind: module +- item[1].path: Belt.Array +- item[1].message: Avoid Belt.Array module references here. +- item[2].kind: value +- item[2].path: Belt.Array.forEach +- item[3].kind: value +- item[3].path: Belt.Array.map +- item[3].message: Prefer Array.map directly. + +namespace: lint +rule: forbidden-reference +instance: 2 +active: true +summary: Report references to configured modules, values, and types. +details: Uses typed references when available so opened modules and aliases resolve to the real symbol path before matching. +settings: +- enabled: true +- severity: warning +- item[1].kind: type +- item[1].path: Js.Json.t +- item[1].message: Avoid Js.Json.t here. namespace: lint rule: single-use-function @@ -16,6 +38,17 @@ details: Counts local function bindings and same-file typed references, then rep settings: - enabled: true - severity: warning +- message: Inline this helper unless it is a meaningful reusable abstraction. + +namespace: lint +rule: alias-avoidance +active: true +summary: Report local aliases that only shorten an existing qualified value, type, or module reference. +details: Flags pass-through aliases such as `let alias = Module.value`, `type alias = Module.t`, and `module Alias = Long.Module.Path` so the qualified reference can be used directly instead. +settings: +- enabled: true +- severity: warning +- message: Use the fully qualified reference directly instead of creating a local alias namespace: rewrite rule: prefer-switch @@ -35,4 +68,4 @@ details: Turns `~label=?Some(expr)` into `~label=expr` when the argument is alre settings: - enabled: true -summary: 4 active, 0 inactive, 4 total +summary: 6 active, 0 inactive, 6 total diff --git a/tests/tools_tests/src/expected/active-rules.json.expected b/tests/tools_tests/src/expected/active-rules.json.expected index 1943a082ab2..fbcc2f8231f 100644 --- a/tests/tools_tests/src/expected/active-rules.json.expected +++ b/tests/tools_tests/src/expected/active-rules.json.expected @@ -1 +1 @@ -{"rules":[{"namespace":"lint","rule":"forbidden-reference","active":true,"summary":"Report references to configured modules, values, and types.","details":"Uses typed references when available so opened modules and aliases resolve to the real symbol path before matching.","settings":{"enabled":true,"severity":"error","items":["Belt.Array.forEach", "Belt.Array.map", "Js.Json.t"]}},{"namespace":"lint","rule":"single-use-function","active":true,"summary":"Report local non-exported functions that are defined once and used once.","details":"Counts local function bindings and same-file typed references, then reports helpers that only have a single real use site.","settings":{"enabled":true,"severity":"warning"}},{"namespace":"rewrite","rule":"prefer-switch","active":true,"summary":"Rewrite `if` and ternary control flow into canonical `switch`.","details":"Simple boolean branches become `switch condition`, and `else if` chains collapse into guarded `switch ()` cases.","settings":{"enabled":true,"if":true,"ternary":true}},{"namespace":"rewrite","rule":"no-optional-some","active":true,"summary":"Rewrite redundant optional-argument wrapping from `?Some(expr)` to the direct labeled form.","details":"Turns `~label=?Some(expr)` into `~label=expr` when the argument is already in an optional position.","settings":{"enabled":true}}],"summary":{"active":4,"inactive":0,"total":4}} +{"rules":[{"namespace":"lint","rule":"forbidden-reference","instance":1,"active":true,"summary":"Report references to configured modules, values, and types.","details":"Uses typed references when available so opened modules and aliases resolve to the real symbol path before matching.","settings":{"enabled":true,"severity":"error","message":"Do not use Belt.Array helpers here. Prefer Stdlib/Array directly.","item[1].kind":"module","item[1].path":"Belt.Array","item[1].message":"Avoid Belt.Array module references here.","item[2].kind":"value","item[2].path":"Belt.Array.forEach","item[3].kind":"value","item[3].path":"Belt.Array.map","item[3].message":"Prefer Array.map directly."}},{"namespace":"lint","rule":"forbidden-reference","instance":2,"active":true,"summary":"Report references to configured modules, values, and types.","details":"Uses typed references when available so opened modules and aliases resolve to the real symbol path before matching.","settings":{"enabled":true,"severity":"warning","item[1].kind":"type","item[1].path":"Js.Json.t","item[1].message":"Avoid Js.Json.t here."}},{"namespace":"lint","rule":"single-use-function","active":true,"summary":"Report local non-exported functions that are defined once and used once.","details":"Counts local function bindings and same-file typed references, then reports helpers that only have a single real use site.","settings":{"enabled":true,"severity":"warning","message":"Inline this helper unless it is a meaningful reusable abstraction."}},{"namespace":"lint","rule":"alias-avoidance","active":true,"summary":"Report local aliases that only shorten an existing qualified value, type, or module reference.","details":"Flags pass-through aliases such as `let alias = Module.value`, `type alias = Module.t`, and `module Alias = Long.Module.Path` so the qualified reference can be used directly instead.","settings":{"enabled":true,"severity":"warning","message":"Use the fully qualified reference directly instead of creating a local alias"}},{"namespace":"rewrite","rule":"prefer-switch","active":true,"summary":"Rewrite `if` and ternary control flow into canonical `switch`.","details":"Simple boolean branches become `switch condition`, and `else if` chains collapse into guarded `switch ()` cases.","settings":{"enabled":true,"if":true,"ternary":true}},{"namespace":"rewrite","rule":"no-optional-some","active":true,"summary":"Rewrite redundant optional-argument wrapping from `?Some(expr)` to the direct labeled form.","details":"Turns `~label=?Some(expr)` into `~label=expr` when the argument is already in an optional position.","settings":{"enabled":true}}],"summary":{"active":6,"inactive":0,"total":6}} diff --git a/tests/tools_tests/src/expected/lint-root.expected b/tests/tools_tests/src/expected/lint-root.expected index 4365e7825a7..bbf63089885 100644 --- a/tests/tools_tests/src/expected/lint-root.expected +++ b/tests/tools_tests/src/expected/lint-root.expected @@ -1,8 +1,71 @@ +severity: warning +rule: alias-avoidance +path: src/lint/AliasAvoidance.res +range: 1:8-1:21 +message: Use the fully qualified reference directly instead of creating a local alias +symbol: ShowFixture.Nested +snippet: +```text +> 1 | module AliasGreeting = ShowFixture.Nested + | ^^^^^^^^^^^^^ +``` + +severity: warning +rule: alias-avoidance +path: src/lint/AliasAvoidance.res +range: 3:6-3:15 +message: Use the fully qualified reference directly instead of creating a local alias +symbol: ShowFixture.item +snippet: +```text + 1 | module AliasGreeting = ShowFixture.Nested +> 3 | type itemAlias = ShowFixture.item + | ^^^^^^^^^ +``` + +severity: warning +rule: alias-avoidance +path: src/lint/AliasAvoidance.res +range: 5:5-5:8 +message: Use the fully qualified reference directly instead of creating a local alias +symbol: String.length +snippet: +```text + 3 | type itemAlias = ShowFixture.item +> 5 | let len = String.length + | ^^^ +``` + +severity: warning +rule: alias-avoidance +path: src/lint/AliasAvoidance.resi +range: 1:8-1:21 +message: Use the fully qualified reference directly instead of creating a local alias +symbol: ShowFixture.Nested +snippet: +```text +> 1 | module AliasGreeting = ShowFixture.Nested + | ^^^^^^^^^^^^^ +``` + +severity: warning +rule: alias-avoidance +path: src/lint/AliasAvoidance.resi +range: 3:6-3:15 +message: Use the fully qualified reference directly instead of creating a local alias +symbol: ShowFixture.item +snippet: +```text + 1 | module AliasGreeting = ShowFixture.Nested +> 3 | type itemAlias = ShowFixture.item + | ^^^^^^^^^ +``` + severity: error rule: forbidden-reference path: src/lint/ForbiddenExplicit.res range: 1:28-1:35 -message: Forbidden reference +message: Do not use Belt.Array helpers here. Prefer Stdlib/Array directly. symbol: Belt_Array.forEach snippet: ```text @@ -10,11 +73,23 @@ snippet: | ^^^^^^^ ``` +severity: error +rule: forbidden-reference +path: src/lint/ForbiddenModule.res +range: 1:23-1:29 +message: Avoid Belt.Array module references here. +symbol: Belt_Array.length +snippet: +```text +> 1 | let size = Belt.Array.length([1, 2]) + | ^^^^^^ +``` + severity: error rule: forbidden-reference path: src/lint/ForbiddenOpen.res range: 3:17-3:20 -message: Forbidden reference +message: Prefer Array.map directly. symbol: Belt_Array.map snippet: ```text @@ -23,11 +98,23 @@ snippet: | ^^^ ``` -severity: error +severity: warning +rule: alias-avoidance +path: src/lint/ForbiddenType.res +range: 1:6-1:10 +message: Use the fully qualified reference directly instead of creating a local alias +symbol: Js.Json.t +snippet: +```text +> 1 | type json = Js.Json.t + | ^^^^ +``` + +severity: warning rule: forbidden-reference path: src/lint/ForbiddenType.res range: 1:21-1:22 -message: Forbidden reference +message: Avoid Js.Json.t here. symbol: Js_json.t snippet: ```text @@ -39,7 +126,7 @@ severity: warning rule: single-use-function path: src/lint/SingleUse.res range: 2:7-2:13 -message: Local function is only used once +message: Inline this helper unless it is a meaningful reusable abstraction. symbol: helper snippet: ```text diff --git a/tests/tools_tests/src/expected/lint-root.json.expected b/tests/tools_tests/src/expected/lint-root.json.expected index 7c4b25ff38a..546b01741e4 100644 --- a/tests/tools_tests/src/expected/lint-root.json.expected +++ b/tests/tools_tests/src/expected/lint-root.json.expected @@ -1 +1 @@ -[{"rule":"forbidden-reference","path":"src/lint/ForbiddenExplicit.res","range":[0,27,0,34],"severity":"error","message":"Forbidden reference","symbol":"Belt_Array.forEach"},{"rule":"forbidden-reference","path":"src/lint/ForbiddenOpen.res","range":[2,16,2,19],"severity":"error","message":"Forbidden reference","symbol":"Belt_Array.map"},{"rule":"forbidden-reference","path":"src/lint/ForbiddenType.res","range":[0,20,0,21],"severity":"error","message":"Forbidden reference","symbol":"Js_json.t"},{"rule":"single-use-function","path":"src/lint/SingleUse.res","range":[1,6,1,12],"severity":"warning","message":"Local function is only used once","symbol":"helper"}] +[{"rule":"alias-avoidance","path":"src/lint/AliasAvoidance.res","range":[0,7,0,20],"severity":"warning","message":"Use the fully qualified reference directly instead of creating a local alias","symbol":"ShowFixture.Nested"},{"rule":"alias-avoidance","path":"src/lint/AliasAvoidance.res","range":[2,5,2,14],"severity":"warning","message":"Use the fully qualified reference directly instead of creating a local alias","symbol":"ShowFixture.item"},{"rule":"alias-avoidance","path":"src/lint/AliasAvoidance.res","range":[4,4,4,7],"severity":"warning","message":"Use the fully qualified reference directly instead of creating a local alias","symbol":"String.length"},{"rule":"alias-avoidance","path":"src/lint/AliasAvoidance.resi","range":[0,7,0,20],"severity":"warning","message":"Use the fully qualified reference directly instead of creating a local alias","symbol":"ShowFixture.Nested"},{"rule":"alias-avoidance","path":"src/lint/AliasAvoidance.resi","range":[2,5,2,14],"severity":"warning","message":"Use the fully qualified reference directly instead of creating a local alias","symbol":"ShowFixture.item"},{"rule":"forbidden-reference","path":"src/lint/ForbiddenExplicit.res","range":[0,27,0,34],"severity":"error","message":"Do not use Belt.Array helpers here. Prefer Stdlib/Array directly.","symbol":"Belt_Array.forEach"},{"rule":"forbidden-reference","path":"src/lint/ForbiddenModule.res","range":[0,22,0,28],"severity":"error","message":"Avoid Belt.Array module references here.","symbol":"Belt_Array.length"},{"rule":"forbidden-reference","path":"src/lint/ForbiddenOpen.res","range":[2,16,2,19],"severity":"error","message":"Prefer Array.map directly.","symbol":"Belt_Array.map"},{"rule":"alias-avoidance","path":"src/lint/ForbiddenType.res","range":[0,5,0,9],"severity":"warning","message":"Use the fully qualified reference directly instead of creating a local alias","symbol":"Js.Json.t"},{"rule":"forbidden-reference","path":"src/lint/ForbiddenType.res","range":[0,20,0,21],"severity":"warning","message":"Avoid Js.Json.t here.","symbol":"Js_json.t"},{"rule":"single-use-function","path":"src/lint/SingleUse.res","range":[1,6,1,12],"severity":"warning","message":"Inline this helper unless it is a meaningful reusable abstraction.","symbol":"helper"}] diff --git a/tests/tools_tests/src/lint/AliasAvoidance.res b/tests/tools_tests/src/lint/AliasAvoidance.res new file mode 100644 index 00000000000..88f4aa6c8ea --- /dev/null +++ b/tests/tools_tests/src/lint/AliasAvoidance.res @@ -0,0 +1,11 @@ +module AliasGreeting = ShowFixture.Nested + +type itemAlias = ShowFixture.item + +let len = String.length + +let run = () => { + ignore(AliasGreeting.makeGreeting("abc")) + let _item: itemAlias = {label: "abc"} + len("abc") +} diff --git a/tests/tools_tests/src/lint/AliasAvoidance.resi b/tests/tools_tests/src/lint/AliasAvoidance.resi new file mode 100644 index 00000000000..435c4cd6e1d --- /dev/null +++ b/tests/tools_tests/src/lint/AliasAvoidance.resi @@ -0,0 +1,6 @@ +module AliasGreeting = ShowFixture.Nested + +type itemAlias = ShowFixture.item + +let len: string => int +let run: unit => int diff --git a/tests/tools_tests/src/lint/ForbiddenModule.res b/tests/tools_tests/src/lint/ForbiddenModule.res new file mode 100644 index 00000000000..f0bc05b9fe9 --- /dev/null +++ b/tests/tools_tests/src/lint/ForbiddenModule.res @@ -0,0 +1 @@ +let size = Belt.Array.length([1, 2]) diff --git a/tests/tools_tests/test.sh b/tests/tools_tests/test.sh index 572768c7c40..254cc03906f 100755 --- a/tests/tools_tests/test.sh +++ b/tests/tools_tests/test.sh @@ -34,7 +34,7 @@ for file in src/docstrings-format/*.{res,resi,md}; do done # Test lint command -for file in src/lint/*.res; do +for file in src/lint/*.{res,resi}; do output="src/expected/$(basename $file).lint.expected" ../../_build/install/default/bin/rescript-tools lint "$file" > "$output" || true if [ "$RUNNER_OS" == "Windows" ]; then diff --git a/tools/src/active_rules.ml b/tools/src/active_rules.ml index f863b638704..ffbab8abb90 100644 --- a/tools/src/active_rules.ml +++ b/tools/src/active_rules.ml @@ -23,6 +23,7 @@ let stringify_rule_json (listed_rule : rule_listing) = [ ("namespace", Some (Protocol.wrapInQuotes listed_rule.namespace)); ("rule", Some (Protocol.wrapInQuotes listed_rule.rule)); + ("instance", listed_rule.instance |> Option.map string_of_int); ("active", Some (string_of_bool listed_rule.active)); ("summary", Some (Protocol.wrapInQuotes listed_rule.summary)); ("details", Some (Protocol.wrapInQuotes listed_rule.details)); @@ -37,11 +38,21 @@ let stringify_rule_text (listed_rule : rule_listing) = [ Printf.sprintf "namespace: %s" listed_rule.namespace; Printf.sprintf "rule: %s" listed_rule.rule; - Printf.sprintf "active: %s" (string_of_bool listed_rule.active); - Printf.sprintf "summary: %s" listed_rule.summary; - Printf.sprintf "details: %s" listed_rule.details; ] in + let lines = + match listed_rule.instance with + | None -> lines + | Some instance -> lines @ [Printf.sprintf "instance: %d" instance] + in + let lines = + lines + @ [ + Printf.sprintf "active: %s" (string_of_bool listed_rule.active); + Printf.sprintf "summary: %s" listed_rule.summary; + Printf.sprintf "details: %s" listed_rule.details; + ] + in let lines = if listed_rule.settings = [] then lines else lines @ ("settings:" :: stringify_settings_text listed_rule.settings) diff --git a/tools/src/lint_analysis.ml b/tools/src/lint_analysis.ml index 5d6edf4d7f0..9cbce20fdbc 100644 --- a/tools/src/lint_analysis.ml +++ b/tools/src/lint_analysis.ml @@ -11,10 +11,49 @@ let starts_with_path ~prefix path = in loop prefix path -let match_forbidden_path items path = +type forbidden_symbol = { + kind: forbidden_reference_kind; + path: string list; +} + +type forbidden_item_match = + | ForbiddenItemExact + | ForbiddenItemModulePrefix of int + +let forbidden_item_match (item : forbidden_reference_item) + (symbol : forbidden_symbol) = + match item.kind with + | ForbiddenReferenceModule -> + if starts_with_path ~prefix:item.path symbol.path then + Some (ForbiddenItemModulePrefix (List.length item.path)) + else None + | ForbiddenReferenceValue | ForbiddenReferenceType -> + if item.kind = symbol.kind && item.path = symbol.path then + Some ForbiddenItemExact + else None + +let forbidden_item_match_is_better candidate best = + match (candidate, best) with + | ForbiddenItemExact, ForbiddenItemModulePrefix _ -> true + | ForbiddenItemModulePrefix _, ForbiddenItemExact -> false + | ForbiddenItemExact, ForbiddenItemExact -> false + | ForbiddenItemModulePrefix candidate_length, ForbiddenItemModulePrefix best_length -> + candidate_length > best_length + +let best_matching_forbidden_item items symbol = items - |> List.find_map (fun item -> - if starts_with_path ~prefix:item path then Some (item, path) else None) + |> List.fold_left (fun best (item : forbidden_reference_item) -> + match forbidden_item_match item symbol with + | None -> best + | Some candidate_match -> ( + match best with + | None -> Some (candidate_match, item) + | Some (best_match, _best_item) -> + if forbidden_item_match_is_better candidate_match best_match then + Some (candidate_match, item) + else best)) + None + |> Option.map snd let path_key path = String.concat "." path @@ -40,23 +79,26 @@ let rec drop_path count path = | [] -> [] | _ :: tail -> drop_path (count - 1) tail -let resolve_exported_path ~env ~package path = - let resolve tip find_declared = - match References.exportedForTip ~env ~path ~package ~tip with - | None -> None - | Some (env, _name, stamp) -> - find_declared env.file.stamps stamp +let tip_of_forbidden_reference_kind = function + | ForbiddenReferenceModule -> Tip.Module + | ForbiddenReferenceValue -> Tip.Value + | ForbiddenReferenceType -> Tip.Type + +let resolve_exported_path ~env ~package ~kind path = + let tip = tip_of_forbidden_reference_kind kind in + match References.exportedForTip ~env ~path ~package ~tip with + | None -> None + | Some (env, _name, stamp) -> ( + match kind with + | ForbiddenReferenceModule -> + Stamps.findModule env.file.stamps stamp |> Option.map (declared_symbol_path ~module_name:env.file.moduleName) - in - match path with - | [] -> Some [env.QueryEnv.file.moduleName] - | _ -> ( - match resolve Tip.Module Stamps.findModule with - | Some _ as resolved -> resolved - | None -> ( - match resolve Tip.Value Stamps.findValue with - | Some _ as resolved -> resolved - | None -> resolve Tip.Type Stamps.findType)) + | ForbiddenReferenceValue -> + Stamps.findValue env.file.stamps stamp + |> Option.map (declared_symbol_path ~module_name:env.file.moduleName) + | ForbiddenReferenceType -> + Stamps.findType env.file.stamps stamp + |> Option.map (declared_symbol_path ~module_name:env.file.moduleName)) let resolve_forbidden_reference_items ~target_path items = match package_for_path target_path with @@ -72,31 +114,35 @@ let resolve_forbidden_reference_items ~target_path items = Hashtbl.add resolved_module_cache (path_key path) resolved; resolved in - let resolve_item_from_module_prefix item = + let resolve_item_from_module_prefix (item : forbidden_reference_item) = let rec loop prefix_length = if prefix_length <= 0 then None else - let module_prefix = take_path prefix_length item in + let module_prefix = take_path prefix_length item.path in match resolve_module_prefix module_prefix with | None -> loop (prefix_length - 1) | Some env -> - let remainder = drop_path prefix_length item in + let remainder = drop_path prefix_length item.path in match remainder with | [] -> Some [env.QueryEnv.file.moduleName] - | _ -> resolve_exported_path ~env ~package remainder + | _ -> resolve_exported_path ~env ~package ~kind:item.kind remainder in - loop (List.length item) + loop (List.length item.path) in - let resolve_item item = - match Hashtbl.find_opt resolved_cache (path_key item) with + let resolve_item (item : forbidden_reference_item) = + let key = + forbidden_reference_kind_to_string item.kind ^ ":" ^ path_key item.path + in + match Hashtbl.find_opt resolved_cache key with | Some resolved -> resolved | None -> let resolved = resolve_item_from_module_prefix item - |> Option.value ~default:item + |> Option.value ~default:item.path in - Hashtbl.add resolved_cache (path_key item) resolved; - resolved + let item = {item with path = resolved} in + Hashtbl.add resolved_cache key item; + item in items |> List.map resolve_item @@ -116,24 +162,178 @@ module Ast = struct | Ppat_constraint (pattern, _) -> collect_binding_names pattern | _ -> [] - let summary_of_file path = + let rec qualified_ident_path (expression : Parsetree.expression) = + match expression.pexp_desc with + | Pexp_ident {txt = (Longident.Ldot _ as lid); _} -> + Some (Utils.flattenLongIdent lid) + | Pexp_constraint (expression, _) + | Pexp_open (_, _, expression) + | Pexp_newtype (_, expression) -> + qualified_ident_path expression + | _ -> None + + let rec qualified_module_path (module_expr : Parsetree.module_expr) = + match module_expr.pmod_desc with + | Pmod_ident {txt = (Longident.Ldot _ as lid); _} -> + Some (Utils.flattenLongIdent lid) + | Pmod_constraint (module_expr, _) -> qualified_module_path module_expr + | _ -> None + + let qualified_type_path (core_type : Parsetree.core_type) = + match core_type.ptyp_desc with + | Ptyp_constr ({txt = (Longident.Ldot _ as lid); _}, _arguments) -> + Some (Utils.flattenLongIdent lid) + | _ -> None + + let alias_avoidance_finding ~path (rule : alias_avoidance_rule) ~loc + symbol_path = + let symbol = Some (String.concat "." symbol_path) in + raw_finding ~rule:"alias-avoidance" ~abs_path:path ~loc + ~severity:rule.severity + ~message:(effective_alias_avoidance_message rule) ?symbol () + + let value_alias_avoidance_findings ~path (rule : alias_avoidance_rule) + bindings = + bindings + |> List.filter_map (fun (binding : Parsetree.value_binding) -> + match collect_binding_names binding.pvb_pat with + | [name_loc] -> ( + match qualified_ident_path binding.pvb_expr with + | None -> None + | Some symbol_path -> + Some + (alias_avoidance_finding ~path rule ~loc:name_loc symbol_path)) + | _ -> None) + + let module_alias_avoidance_finding ~path (rule : alias_avoidance_rule) + (module_binding : Parsetree.module_binding) = + qualified_module_path module_binding.pmb_expr + |> Option.map (fun symbol_path -> + alias_avoidance_finding ~path rule ~loc:module_binding.pmb_name.loc + symbol_path) + + let module_declaration_alias_avoidance_finding ~path + (rule : alias_avoidance_rule) (module_declaration : Parsetree.module_declaration) + = + match module_declaration.pmd_type.pmty_desc with + | Pmty_alias {txt = (Longident.Ldot _ as lid); _} -> + Some + (alias_avoidance_finding ~path rule ~loc:module_declaration.pmd_name.loc + (Utils.flattenLongIdent lid)) + | _ -> None + + let local_module_alias_avoidance_finding ~path (rule : alias_avoidance_rule) + (name : string Location.loc) (module_expr : Parsetree.module_expr) = + qualified_module_path module_expr + |> Option.map (fun symbol_path -> + alias_avoidance_finding ~path rule ~loc:name.loc symbol_path) + + let type_alias_avoidance_findings ~path (rule : alias_avoidance_rule) decls = + decls + |> List.filter_map (fun (decl : Parsetree.type_declaration) -> + match decl.ptype_manifest with + | None -> None + | Some manifest -> ( + match qualified_type_path manifest with + | None -> None + | Some symbol_path -> + Some + (alias_avoidance_finding ~path rule ~loc:decl.ptype_name.loc + symbol_path))) + + let summary_of_file ?alias_avoidance_rule path = let local_function_bindings = ref StringSet.empty in + let alias_findings = ref [] in + let inspect_bindings bindings = + bindings + |> List.iter (fun (binding : Parsetree.value_binding) -> + if is_function_expression binding.pvb_expr then + collect_binding_names binding.pvb_pat + |> List.iter (fun loc -> + local_function_bindings := + StringSet.add (loc_key loc) !local_function_bindings)); + match alias_avoidance_rule with + | None -> () + | Some rule -> + alias_findings := + value_alias_avoidance_findings ~path rule bindings @ !alias_findings + in let iterator = let open Ast_iterator in { Ast_iterator.default_iterator with + structure_item = + (fun iter structure_item -> + (match structure_item.pstr_desc with + | Pstr_value (_rec_flag, bindings) -> inspect_bindings bindings + | Pstr_type (_rec_flag, decls) -> ( + match alias_avoidance_rule with + | None -> () + | Some rule -> + alias_findings := + type_alias_avoidance_findings ~path rule decls + @ !alias_findings) + | Pstr_module module_binding -> ( + match alias_avoidance_rule with + | None -> () + | Some rule -> ( + match module_alias_avoidance_finding ~path rule module_binding with + | None -> () + | Some finding -> alias_findings := finding :: !alias_findings)) + | Pstr_recmodule module_bindings -> ( + match alias_avoidance_rule with + | None -> () + | Some rule -> + alias_findings := + (module_bindings + |> List.filter_map (module_alias_avoidance_finding ~path rule)) + @ !alias_findings) + | _ -> ()); + Ast_iterator.default_iterator.structure_item iter structure_item); + signature_item = + (fun iter signature_item -> + (match signature_item.psig_desc with + | Psig_type (_rec_flag, decls) -> ( + match alias_avoidance_rule with + | None -> () + | Some rule -> + alias_findings := + type_alias_avoidance_findings ~path rule decls + @ !alias_findings) + | Psig_module module_declaration -> ( + match alias_avoidance_rule with + | None -> () + | Some rule -> ( + match + module_declaration_alias_avoidance_finding ~path rule + module_declaration + with + | None -> () + | Some finding -> alias_findings := finding :: !alias_findings)) + | Psig_recmodule module_declarations -> ( + match alias_avoidance_rule with + | None -> () + | Some rule -> + alias_findings := + (module_declarations + |> List.filter_map + (module_declaration_alias_avoidance_finding ~path rule)) + @ !alias_findings) + | _ -> ()); + Ast_iterator.default_iterator.signature_item iter signature_item); expr = (fun iter expression -> (match expression.pexp_desc with - | Pexp_let (_rec_flag, bindings, _) -> - bindings - |> List.iter (fun (binding : Parsetree.value_binding) -> - if is_function_expression binding.pvb_expr then - collect_binding_names binding.pvb_pat - |> List.iter (fun loc -> - local_function_bindings := - StringSet.add (loc_key loc) - !local_function_bindings)) + | Pexp_let (_rec_flag, bindings, _) -> inspect_bindings bindings + | Pexp_letmodule (name, module_expr, _) -> ( + match alias_avoidance_rule with + | None -> () + | Some rule -> ( + match + local_module_alias_avoidance_finding ~path rule name module_expr + with + | None -> () + | Some finding -> alias_findings := finding :: !alias_findings)) | _ -> ()); Ast_iterator.default_iterator.expr iter expression); } @@ -187,10 +387,16 @@ module Ast = struct symbol = None; }) in - {parse_errors; local_function_bindings = !local_function_bindings} + ( {parse_errors; local_function_bindings = !local_function_bindings}, + List.rev !alias_findings ) end module Typed = struct + let kind_of_tip = function + | Tip.Module -> ForbiddenReferenceModule + | Tip.Value -> ForbiddenReferenceValue + | Tip.Type | Tip.Field _ | Tip.Constructor _ -> ForbiddenReferenceType + let resolve_global_symbol ~package ~module_name ~path ~tip = match ProcessCmt.fileForModule module_name ~package with | None -> None @@ -252,39 +458,41 @@ module Typed = struct declared_symbol_path ~module_name:file.moduleName declared @ [constructor_name]) - let symbol_path (full : SharedTypes.full) (loc_item : locItem) = + let symbol (full : SharedTypes.full) (loc_item : locItem) = match loc_item.locType with | Typed (_, _typ, LocalReference (stamp, tip)) -> resolve_local_symbol ~file:full.file ~tip stamp + |> Option.map (fun path -> {kind = kind_of_tip tip; path}) | Typed (_, _typ, GlobalReference (module_name, path, tip)) -> resolve_global_symbol ~package:full.package ~module_name ~path ~tip + |> Option.map (fun path -> {kind = kind_of_tip tip; path}) | Typed (_, _, (Definition _ | NotFound)) | LModule _ | TopLevelModule _ | Constant _ | TypeDefinition _ -> None let forbidden_reference_findings ~config ~path (full : SharedTypes.full) = - if - (not config.forbidden_reference.enabled) - || config.forbidden_reference.items = [] - then [] - else - full.extra.locItems - |> List.filter_map (fun loc_item -> - match symbol_path full loc_item with + let matching_rule symbol = + config.forbidden_reference + |> List.find_map (fun (rule : forbidden_reference_rule) -> + if (not rule.enabled) || rule.items = [] then None + else + best_matching_forbidden_item rule.items symbol + |> Option.map (fun item -> (rule, item))) + in + full.extra.locItems + |> List.filter_map (fun loc_item -> + match symbol full loc_item with + | None -> None + | Some symbol -> + match matching_rule symbol with | None -> None - | Some symbol_path -> ( - match - match_forbidden_path config.forbidden_reference.items - symbol_path - with - | None -> None - | Some (_item, matched_path) -> - let symbol = Some (String.concat "." matched_path) in - Some - (raw_finding ~rule:"forbidden-reference" ~abs_path:path - ~loc:loc_item.loc - ~severity:config.forbidden_reference.severity - ~message:"Forbidden reference" ?symbol ()))) + | Some (rule, item) -> + let symbol = Some (String.concat "." symbol.path) in + Some + (raw_finding ~rule:"forbidden-reference" ~abs_path:path + ~loc:loc_item.loc ~severity:rule.severity + ~message:(effective_forbidden_reference_item_message rule item) + ?symbol ())) let is_function_type typ = match (Shared.dig typ).desc with @@ -295,6 +503,7 @@ module Typed = struct (full : SharedTypes.full) = if not config.single_use_function.enabled then [] else + let rule = config.single_use_function in let findings = ref [] in Stamps.iterValues (fun stamp (declared : Types.type_expr Declared.t) -> @@ -312,9 +521,9 @@ module Typed = struct let symbol = Some declared.name.txt in findings := raw_finding ~rule:"single-use-function" ~abs_path:path - ~loc:declared.name.loc - ~severity:config.single_use_function.severity - ~message:"Local function is only used once" ?symbol () + ~loc:declared.name.loc ~severity:rule.severity + ~message:(effective_single_use_function_message rule) ?symbol + () :: !findings) full.file.stamps; List.rev !findings @@ -401,19 +610,26 @@ let compare_raw_findings left right = Lint_support.Range.of_loc right.loc ) let analyze_file ~config path = - let ast = Ast.summary_of_file path in + let alias_avoidance_rule = + if config.alias_avoidance.enabled then Some config.alias_avoidance else None + in + let ast, alias_avoidance_findings = + Ast.summary_of_file ?alias_avoidance_rule path + in let findings = ref ast.parse_errors in - (if ast.parse_errors = [] then - match - if has_typed_artifact path then Cmt.loadFullCmtFromPath ~path else None - with - | None -> () - | Some full -> - findings := - Typed.forbidden_reference_findings ~config ~path full - @ Typed.single_use_function_findings ~config ~path - ~local_function_bindings:ast.local_function_bindings full - @ !findings); + if ast.parse_errors = [] then begin + findings := alias_avoidance_findings @ !findings; + match + if has_typed_artifact path then Cmt.loadFullCmtFromPath ~path else None + with + | None -> () + | Some full -> + findings := + Typed.forbidden_reference_findings ~config ~path full + @ Typed.single_use_function_findings ~config ~path + ~local_function_bindings:ast.local_function_bindings full + @ !findings + end; !findings type analyzed_target = {display_base: string; findings: raw_finding list} @@ -423,12 +639,13 @@ let analyze_target ~config target_path = { config with forbidden_reference = - { - config.forbidden_reference with - items = - resolve_forbidden_reference_items ~target_path - config.forbidden_reference.items; - }; + config.forbidden_reference + |> List.map (fun (rule : forbidden_reference_rule) -> + { + rule with + items = + resolve_forbidden_reference_items ~target_path rule.items; + }); } in let files = collect_files target_path in diff --git a/tools/src/lint_config.ml b/tools/src/lint_config.ml index 6bed242c359..a6d82155c50 100644 --- a/tools/src/lint_config.ml +++ b/tools/src/lint_config.ml @@ -1,10 +1,20 @@ open Analysis open Lint_shared +let default_forbidden_reference_rule : forbidden_reference_rule = + {enabled = true; severity = SeverityError; message = None; items = []} + +let default_single_use_function_rule : single_use_function_rule = + {enabled = true; severity = SeverityWarning; message = None} + +let default_alias_avoidance_rule : alias_avoidance_rule = + {enabled = true; severity = SeverityWarning; message = None} + let default_config = { - forbidden_reference = {enabled = true; severity = SeverityError; items = []}; - single_use_function = {enabled = true; severity = SeverityWarning}; + forbidden_reference = [default_forbidden_reference_rule]; + single_use_function = default_single_use_function_rule; + alias_avoidance = default_alias_avoidance_rule; rewrite = { prefer_switch = @@ -18,6 +28,77 @@ let parse_rule_severity ~default json = | None -> default | Some severity -> Option.value ~default (severity_of_string severity) +let parse_rule_message json = + Option.bind (json |> Json.get "message") Json.string + +let parse_forbidden_reference_kind = function + | "module" -> Some ForbiddenReferenceModule + | "value" -> Some ForbiddenReferenceValue + | "type" -> Some ForbiddenReferenceType + | _ -> None + +let result_all results = + let rec loop acc = function + | [] -> Ok (List.rev acc) + | Ok value :: rest -> loop (value :: acc) rest + | Error _ as error :: _ -> error + in + loop [] results + +let parse_rule_objects ~rule = function + | None -> Ok [] + | Some (Json.Object _ as rule_json) -> Ok [rule_json] + | Some (Json.Array items) -> + items + |> List.mapi (fun index item -> + match item with + | Json.Object _ as rule_json -> Ok rule_json + | _ -> + Error + (Printf.sprintf + "error: lint rule `%s` instance %d must be an object" rule + (index + 1))) + |> result_all + | Some _ -> + Error + (Printf.sprintf + "error: lint rule `%s` must be an object or array of objects" rule) + +let parse_singleton_rule ~rule ~default parse json = + Result.bind + (parse_rule_objects ~rule json) + (function + | [] -> Ok default + | [rule_json] -> parse rule_json + | _ -> + Error + (Printf.sprintf + "error: lint rule `%s` does not support multiple instances" rule)) + +let parse_rule_instances ~rule ~default parse json = + Result.bind + (parse_rule_objects ~rule json) + (function + | [] -> Ok [default] + | rule_jsons -> rule_jsons |> List.map parse |> result_all) + +let parse_item_objects ~rule = function + | None -> Ok [] + | Some (Json.Array items) -> + items + |> List.mapi (fun index item -> + match item with + | Json.Object _ as item_json -> Ok item_json + | _ -> + Error + (Printf.sprintf + "error: lint rule `%s` item %d must be an object" rule + (index + 1))) + |> result_all + | Some _ -> + Error + (Printf.sprintf "error: lint rule `%s` field `items` must be an array of objects" rule) + let parse_config_json json = let lint_json = json |> Json.get "lint" in let rules = @@ -44,34 +125,87 @@ let parse_config_json json = | Some rules -> rules |> Json.get name | None -> None in - let forbidden_reference = - match get_rule "forbidden-reference" with - | Some rule -> - { + let parse_forbidden_reference_rule rule : + (forbidden_reference_rule, string) result = + let parse_item item_json : (forbidden_reference_item, string) result = + let kind = + match Option.bind (item_json |> Json.get "kind") Json.string with + | None -> + Error + "error: lint rule `forbidden-reference` items must include `kind`" + | Some kind -> ( + match parse_forbidden_reference_kind kind with + | Some kind -> Ok kind + | None -> + Error + (Printf.sprintf + "error: lint rule `forbidden-reference` item kind `%s` must be one of `module`, `value`, or `type`" + kind)) + in + let path = + match Option.bind (item_json |> Json.get "path") Json.string with + | None -> + Error + "error: lint rule `forbidden-reference` items must include `path`" + | Some path -> + let path = + path |> String.split_on_char '.' + |> List.filter (fun segment -> segment <> "") + in + if path = [] then + Error + "error: lint rule `forbidden-reference` item path must not be empty" + else Ok path + in + Result.bind kind (fun kind -> + Result.map + (fun path -> + { + kind; + path; + message = parse_rule_message item_json; + }) + path) + in + Result.bind + (parse_item_objects ~rule:"forbidden-reference" (rule |> Json.get "items")) + (fun items_json -> + Result.map + (fun items -> + ({ + enabled = + Lint_support.Json.bool_with_default ~default:true rule + "enabled"; + severity = + parse_rule_severity + ~default:default_forbidden_reference_rule.severity rule; + message = parse_rule_message rule; + items; + } : forbidden_reference_rule)) + (items_json |> List.map parse_item |> result_all)) + in + let parse_single_use_function_rule rule : + (single_use_function_rule, string) result = + Ok + ({ enabled = Lint_support.Json.bool_with_default ~default:true rule "enabled"; severity = - parse_rule_severity - ~default:default_config.forbidden_reference.severity rule; - items = - Lint_support.Json.string_array rule "items" - |> List.map (fun item -> - item |> String.split_on_char '.' - |> List.filter (fun segment -> segment <> "")); - } - | None -> default_config.forbidden_reference + parse_rule_severity ~default:default_single_use_function_rule.severity + rule; + message = parse_rule_message rule; + } : single_use_function_rule) in - let single_use_function = - match get_rule "single-use-function" with - | Some rule -> - { + let parse_alias_avoidance_rule rule : (alias_avoidance_rule, string) result = + Ok + ({ enabled = Lint_support.Json.bool_with_default ~default:true rule "enabled"; severity = - parse_rule_severity - ~default:default_config.single_use_function.severity rule; - } - | None -> default_config.single_use_function + parse_rule_severity ~default:default_alias_avoidance_rule.severity + rule; + message = parse_rule_message rule; + } : alias_avoidance_rule) in let prefer_switch = match get_rewrite_rule "prefer-switch" with @@ -98,11 +232,27 @@ let parse_config_json json = } | None -> default_config.rewrite.no_optional_some in - { - forbidden_reference; - single_use_function; - rewrite = {prefer_switch; no_optional_some}; - } + Result.bind + (parse_rule_instances ~rule:"forbidden-reference" + ~default:default_forbidden_reference_rule + parse_forbidden_reference_rule (get_rule "forbidden-reference")) + (fun forbidden_reference -> + Result.bind + (parse_singleton_rule ~rule:"single-use-function" + ~default:default_single_use_function_rule + parse_single_use_function_rule (get_rule "single-use-function")) + (fun single_use_function -> + Result.map + (fun alias_avoidance -> + { + forbidden_reference; + single_use_function; + alias_avoidance; + rewrite = {prefer_switch; no_optional_some}; + }) + (parse_singleton_rule ~rule:"alias-avoidance" + ~default:default_alias_avoidance_rule + parse_alias_avoidance_rule (get_rule "alias-avoidance")))) let discover_config_path start_path = let rec loop path = @@ -135,4 +285,4 @@ let load ?config_path target_path = match config_path with | None -> Ok default_config | Some path -> - Lint_support.Json.read_file path |> Result.map parse_config_json + Result.bind (Lint_support.Json.read_file path) parse_config_json diff --git a/tools/src/lint_shared.ml b/tools/src/lint_shared.ml index 2c8b4c0668b..d684229c33d 100644 --- a/tools/src/lint_shared.ml +++ b/tools/src/lint_shared.ml @@ -20,12 +20,34 @@ type raw_finding = { symbol: string option; } -type single_use_function_rule = {enabled: bool; severity: severity} +type single_use_function_rule = { + enabled: bool; + severity: severity; + message: string option; +} + +type forbidden_reference_kind = + | ForbiddenReferenceModule + | ForbiddenReferenceValue + | ForbiddenReferenceType + +type forbidden_reference_item = { + kind: forbidden_reference_kind; + path: string list; + message: string option; +} type forbidden_reference_rule = { enabled: bool; severity: severity; - items: string list list; + message: string option; + items: forbidden_reference_item list; +} + +type alias_avoidance_rule = { + enabled: bool; + severity: severity; + message: string option; } type prefer_switch_rule = { @@ -42,8 +64,9 @@ type rewrite_config = { } type config = { - forbidden_reference: forbidden_reference_rule; + forbidden_reference: forbidden_reference_rule list; single_use_function: single_use_function_rule; + alias_avoidance: alias_avoidance_rule; rewrite: rewrite_config; } @@ -58,6 +81,7 @@ type rule_info = { type rule_listing = { namespace: string; rule: string; + instance: int option; active: bool; summary: string; details: string; @@ -78,6 +102,18 @@ let severity_to_string = function | SeverityError -> "error" | SeverityWarning -> "warning" +let forbidden_reference_kind_to_string = function + | ForbiddenReferenceModule -> "module" + | ForbiddenReferenceValue -> "value" + | ForbiddenReferenceType -> "type" + +let forbidden_reference_default_message = "Forbidden reference" + +let single_use_function_default_message = "Local function is only used once" + +let alias_avoidance_default_message = + "Use the fully qualified reference directly instead of creating a local alias" + let rule_setting_value_to_text = function | RuleSettingBool value -> string_of_bool value | RuleSettingString value -> value @@ -107,6 +143,20 @@ let single_use_function_rule_info = rewrite_note = None; } +let alias_avoidance_rule_info = + { + namespace = "lint"; + rule = "alias-avoidance"; + summary = + "Report local aliases that only shorten an existing qualified value, \ + type, or module reference."; + details = + "Flags pass-through aliases such as `let alias = Module.value`, `type \ + alias = Module.t`, and `module Alias = Long.Module.Path` so the \ + qualified reference can be used directly instead."; + rewrite_note = None; + } + let prefer_switch_rule_info = { namespace = "rewrite"; @@ -135,13 +185,15 @@ let configurable_rule_infos = [ forbidden_reference_rule_info; single_use_function_rule_info; + alias_avoidance_rule_info; prefer_switch_rule_info; no_optional_some_rule_info; ] let rewrite_rule_infos = configurable_rule_infos - |> List.filter (fun (rule_info : rule_info) -> rule_info.namespace = "rewrite") + |> List.filter (fun (rule_info : rule_info) -> + rule_info.namespace = "rewrite") let rewrite_note_for_rule rule = rewrite_rule_infos @@ -149,64 +201,153 @@ let rewrite_note_for_rule rule = if rule_info.rule = rule then rule_info.rewrite_note else None) |> Option.value ~default:"applied rewrite rule" +let effective_forbidden_reference_message (rule : forbidden_reference_rule) = + Option.value rule.message ~default:forbidden_reference_default_message + +let effective_forbidden_reference_item_message + (rule : forbidden_reference_rule) (item : forbidden_reference_item) = + Option.value item.message ~default:(effective_forbidden_reference_message rule) + +let forbidden_reference_message_settings (rule : forbidden_reference_rule) = + let has_item_message = + rule.items |> List.exists (fun (item : forbidden_reference_item) -> + item.message <> None) + in + let has_item_without_message = + rule.items |> List.exists (fun (item : forbidden_reference_item) -> + item.message = None) + in + match rule.message with + | Some message -> [("message", RuleSettingString message)] + | None when has_item_message && not has_item_without_message -> [] + | None when has_item_message -> + [("default-message", RuleSettingString forbidden_reference_default_message)] + | None -> [("message", RuleSettingString forbidden_reference_default_message)] + +let effective_single_use_function_message (rule : single_use_function_rule) = + Option.value rule.message ~default:single_use_function_default_message + +let effective_alias_avoidance_message (rule : alias_avoidance_rule) = + Option.value rule.message ~default:alias_avoidance_default_message + let rule_listings_of_config (config : config) = - let item_paths = config.forbidden_reference.items |> List.map (String.concat ".") in - [ - { - namespace = forbidden_reference_rule_info.namespace; - rule = forbidden_reference_rule_info.rule; - active = - config.forbidden_reference.enabled && config.forbidden_reference.items <> []; - summary = forbidden_reference_rule_info.summary; - details = forbidden_reference_rule_info.details; - settings = - [ - ("enabled", RuleSettingBool config.forbidden_reference.enabled); - ( "severity", - RuleSettingString - (severity_to_string config.forbidden_reference.severity) ); - ("items", RuleSettingStringList item_paths); - ]; - }; - { - namespace = single_use_function_rule_info.namespace; - rule = single_use_function_rule_info.rule; - active = config.single_use_function.enabled; - summary = single_use_function_rule_info.summary; - details = single_use_function_rule_info.details; - settings = - [ - ("enabled", RuleSettingBool config.single_use_function.enabled); - ( "severity", - RuleSettingString - (severity_to_string config.single_use_function.severity) ); - ]; - }; - { - namespace = prefer_switch_rule_info.namespace; - rule = prefer_switch_rule_info.rule; - active = - config.rewrite.prefer_switch.enabled - && (config.rewrite.prefer_switch.rewrite_if - || config.rewrite.prefer_switch.rewrite_ternary); - summary = prefer_switch_rule_info.summary; - details = prefer_switch_rule_info.details; - settings = - [ - ("enabled", RuleSettingBool config.rewrite.prefer_switch.enabled); - ("if", RuleSettingBool config.rewrite.prefer_switch.rewrite_if); - ("ternary", RuleSettingBool config.rewrite.prefer_switch.rewrite_ternary); - ]; - }; - { - namespace = no_optional_some_rule_info.namespace; - rule = no_optional_some_rule_info.rule; - active = config.rewrite.no_optional_some.enabled; - summary = no_optional_some_rule_info.summary; - details = no_optional_some_rule_info.details; - settings = [("enabled", RuleSettingBool config.rewrite.no_optional_some.enabled)]; - }; - ] + let instance_if_many total index = + if total > 1 then Some (index + 1) else None + in + let forbidden_reference_listings = + config.forbidden_reference + |> List.mapi (fun index (rule : forbidden_reference_rule) -> + let item_settings = + rule.items + |> List.mapi (fun item_index (item : forbidden_reference_item) -> + let prefix = + Printf.sprintf "item[%d]" (item_index + 1) + in + let base_settings = + [ + ( prefix ^ ".kind", + RuleSettingString + (forbidden_reference_kind_to_string item.kind) ); + (prefix ^ ".path", RuleSettingString (String.concat "." item.path)); + ] + in + match item.message with + | None -> base_settings + | Some message -> + base_settings + @ [(prefix ^ ".message", RuleSettingString message)]) + |> List.concat + in + { + namespace = forbidden_reference_rule_info.namespace; + rule = forbidden_reference_rule_info.rule; + instance = + instance_if_many (List.length config.forbidden_reference) index; + active = rule.enabled && rule.items <> []; + summary = forbidden_reference_rule_info.summary; + details = forbidden_reference_rule_info.details; + settings = + [ + ("enabled", RuleSettingBool rule.enabled); + ( "severity", + RuleSettingString (severity_to_string rule.severity) ); + ] + @ forbidden_reference_message_settings rule + @ item_settings; + }) + in + let single_use_function_listings = + let rule = config.single_use_function in + [ + { + namespace = single_use_function_rule_info.namespace; + rule = single_use_function_rule_info.rule; + instance = None; + active = rule.enabled; + summary = single_use_function_rule_info.summary; + details = single_use_function_rule_info.details; + settings = + [ + ("enabled", RuleSettingBool rule.enabled); + ("severity", RuleSettingString (severity_to_string rule.severity)); + ( "message", + RuleSettingString (effective_single_use_function_message rule) ); + ]; + }; + ] + in + let alias_avoidance_listings = + let rule = config.alias_avoidance in + [ + { + namespace = alias_avoidance_rule_info.namespace; + rule = alias_avoidance_rule_info.rule; + instance = None; + active = rule.enabled; + summary = alias_avoidance_rule_info.summary; + details = alias_avoidance_rule_info.details; + settings = + [ + ("enabled", RuleSettingBool rule.enabled); + ("severity", RuleSettingString (severity_to_string rule.severity)); + ( "message", + RuleSettingString (effective_alias_avoidance_message rule) ); + ]; + }; + ] + in + forbidden_reference_listings @ single_use_function_listings + @ alias_avoidance_listings + @ [ + { + namespace = prefer_switch_rule_info.namespace; + rule = prefer_switch_rule_info.rule; + instance = None; + active = + config.rewrite.prefer_switch.enabled + && (config.rewrite.prefer_switch.rewrite_if + || config.rewrite.prefer_switch.rewrite_ternary); + summary = prefer_switch_rule_info.summary; + details = prefer_switch_rule_info.details; + settings = + [ + ("enabled", RuleSettingBool config.rewrite.prefer_switch.enabled); + ("if", RuleSettingBool config.rewrite.prefer_switch.rewrite_if); + ( "ternary", + RuleSettingBool config.rewrite.prefer_switch.rewrite_ternary ); + ]; + }; + { + namespace = no_optional_some_rule_info.namespace; + rule = no_optional_some_rule_info.rule; + instance = None; + active = config.rewrite.no_optional_some.enabled; + summary = no_optional_some_rule_info.summary; + details = no_optional_some_rule_info.details; + settings = + [("enabled", RuleSettingBool config.rewrite.no_optional_some.enabled)]; + }; + ] let severity_of_string = function | "error" -> Some SeverityError From 76b8b1eee23bab49fdec34fe0f18b1e6b1cfaf0a Mon Sep 17 00:00:00 2001 From: Gabriel Nordeborn Date: Sun, 19 Apr 2026 19:54:55 +0200 Subject: [PATCH 10/17] add preferred type syntax lint and rewrite Signed-off-by: Gabriel Nordeborn --- docs/rescript_ai.md | 34 +++- tests/tools_tests/.rescript-lint.json | 8 + .../PreferredTypeSyntax.res.lint.expected | 55 ++++++ ...PreferredTypeSyntax.res.lint.json.expected | 1 + .../PreferredTypeSyntax.resi.lint.expected | 55 ++++++ ...referredTypeSyntax.resi.lint.json.expected | 1 + ...ferredTypeSyntax.res.rewrite.diff.expected | 23 +++ ...dTypeSyntax.res.rewrite.diff.json.expected | 1 + ...tePreferredTypeSyntax.res.rewrite.expected | 6 + ...ferredTypeSyntax.res.rewrite.json.expected | 1 + ...rredTypeSyntax.res.rewrite.source.expected | 7 + ...erredTypeSyntax.resi.rewrite.diff.expected | 23 +++ ...TypeSyntax.resi.rewrite.diff.json.expected | 1 + ...ePreferredTypeSyntax.resi.rewrite.expected | 6 + ...erredTypeSyntax.resi.rewrite.json.expected | 1 + ...redTypeSyntax.resi.rewrite.source.expected | 7 + .../src/expected/active-rules.expected | 22 ++- .../src/expected/active-rules.json.expected | 2 +- .../src/expected/lint-root.expected | 112 ++++++++++++ .../src/expected/lint-root.json.expected | 2 +- .../src/expected/rewrite-root.diff.expected | 46 ++++- .../expected/rewrite-root.diff.json.expected | 2 +- .../src/expected/rewrite-root.expected | 12 +- .../src/expected/rewrite-root.json.expected | 2 +- .../src/lint/PreferredTypeSyntax.res | 7 + .../src/lint/PreferredTypeSyntax.resi | 7 + .../rewrite/RewritePreferredTypeSyntax.res | 7 + .../rewrite/RewritePreferredTypeSyntax.resi | 7 + tests/tools_tests/test.sh | 6 +- tools/src/lint_analysis.ml | 131 +++++++++----- tools/src/lint_config.ml | 160 ++++++++++++------ tools/src/lint_shared.ml | 107 ++++++++++-- tools/src/rewrite.ml | 154 ++++++++++------- 33 files changed, 826 insertions(+), 190 deletions(-) create mode 100644 tests/tools_tests/src/expected/PreferredTypeSyntax.res.lint.expected create mode 100644 tests/tools_tests/src/expected/PreferredTypeSyntax.res.lint.json.expected create mode 100644 tests/tools_tests/src/expected/PreferredTypeSyntax.resi.lint.expected create mode 100644 tests/tools_tests/src/expected/PreferredTypeSyntax.resi.lint.json.expected create mode 100644 tests/tools_tests/src/expected/RewritePreferredTypeSyntax.res.rewrite.diff.expected create mode 100644 tests/tools_tests/src/expected/RewritePreferredTypeSyntax.res.rewrite.diff.json.expected create mode 100644 tests/tools_tests/src/expected/RewritePreferredTypeSyntax.res.rewrite.expected create mode 100644 tests/tools_tests/src/expected/RewritePreferredTypeSyntax.res.rewrite.json.expected create mode 100644 tests/tools_tests/src/expected/RewritePreferredTypeSyntax.res.rewrite.source.expected create mode 100644 tests/tools_tests/src/expected/RewritePreferredTypeSyntax.resi.rewrite.diff.expected create mode 100644 tests/tools_tests/src/expected/RewritePreferredTypeSyntax.resi.rewrite.diff.json.expected create mode 100644 tests/tools_tests/src/expected/RewritePreferredTypeSyntax.resi.rewrite.expected create mode 100644 tests/tools_tests/src/expected/RewritePreferredTypeSyntax.resi.rewrite.json.expected create mode 100644 tests/tools_tests/src/expected/RewritePreferredTypeSyntax.resi.rewrite.source.expected create mode 100644 tests/tools_tests/src/lint/PreferredTypeSyntax.res create mode 100644 tests/tools_tests/src/lint/PreferredTypeSyntax.resi create mode 100644 tests/tools_tests/src/rewrite/RewritePreferredTypeSyntax.res create mode 100644 tests/tools_tests/src/rewrite/RewritePreferredTypeSyntax.resi diff --git a/docs/rescript_ai.md b/docs/rescript_ai.md index cde94592280..20f4e108a49 100644 --- a/docs/rescript_ai.md +++ b/docs/rescript_ai.md @@ -7,6 +7,29 @@ The lint command is the first concrete piece, but the intended surface is broade A starter agent skill template for this workflow lives at `docs/skills/rescript-ai-template/SKILL.md`. +## PR description + +Keep this section updated as the command surface and rule set change so the PR +description stays current. + +```text +Add AI-oriented `rescript-tools` commands + +Commands +- `lint`: Runs configurable AI-oriented lint checks on a file or project root using source and typed information. +- `rewrite`: Rewrites source into a narrower agent-oriented normal form, with optional diff output. +- `active-rules`: Lists lint and rewrite rules, whether they are active, and how they are configured. +- `show`: Returns hover-style semantic information for a symbol path. +- `find-references`: Finds references from either a symbol path or a source location. + +Initial rules +- Lint: `forbidden-reference`, `single-use-function`, `alias-avoidance`, `preferred-type-syntax` +- Rewrite: `prefer-switch`, `no-optional-some`, `preferred-type-syntax` + +Support +- Add `.rescript-lint.json` config support, AI tooling docs, and golden tests for the new command surface +``` + ## Goal Build AI-oriented tooling that: @@ -278,9 +301,9 @@ This section is intentionally a scratchpad for future rules. - enforce regular string literals instead of template strings when interpolation or other template-only behavior is not needed -### Builtin type spellings +### Preferred type syntax -- enforce builtin type spellings where available +- enforce canonical builtin type syntax where available - example: prefer `dict<>` over `Dict.t<>` ### Dict normalization @@ -342,13 +365,18 @@ Lint and rewrite config should each live under their own namespace in `.rescript }, "single-use-function": { "severity": "warning" + }, + "preferred-type-syntax": { + "severity": "warning", + "dict": true } } }, "rewrite": { "rules": { "prefer-switch": {"enabled": true, "if": true, "ternary": true}, - "no-optional-some": {"enabled": true} + "no-optional-some": {"enabled": true}, + "preferred-type-syntax": {"enabled": true, "dict": true} } } } diff --git a/tests/tools_tests/.rescript-lint.json b/tests/tools_tests/.rescript-lint.json index 6fb39b0af04..011d2335c9a 100644 --- a/tests/tools_tests/.rescript-lint.json +++ b/tests/tools_tests/.rescript-lint.json @@ -39,6 +39,10 @@ }, "alias-avoidance": { "severity": "warning" + }, + "preferred-type-syntax": { + "severity": "warning", + "dict": true } } }, @@ -51,6 +55,10 @@ }, "no-optional-some": { "enabled": true + }, + "preferred-type-syntax": { + "enabled": true, + "dict": true } } } diff --git a/tests/tools_tests/src/expected/PreferredTypeSyntax.res.lint.expected b/tests/tools_tests/src/expected/PreferredTypeSyntax.res.lint.expected new file mode 100644 index 00000000000..a0fd101304f --- /dev/null +++ b/tests/tools_tests/src/expected/PreferredTypeSyntax.res.lint.expected @@ -0,0 +1,55 @@ +severity: warning +rule: preferred-type-syntax +path: src/lint/PreferredTypeSyntax.res +range: 2:10-2:16 +message: Prefer `dict<_>` over `Dict.t<_>` +symbol: Dict.t +snippet: +```text + 1 | type wrapped = { +> 2 | items: Dict.t, + | ^^^^^^ + 3 | names: Stdlib.Dict.t, +``` + +severity: warning +rule: preferred-type-syntax +path: src/lint/PreferredTypeSyntax.res +range: 6:16-6:22 +message: Prefer `dict<_>` over `Dict.t<_>` +symbol: Dict.t +snippet: +```text + 4 | } +> 6 | let dictValue: Dict.t = dict{} + | ^^^^^^ + 7 | let stdlibDictValue: Stdlib.Dict.t = dict{} +``` + +severity: warning +rule: preferred-type-syntax +path: src/lint/PreferredTypeSyntax.res +range: 3:10-3:23 +message: Prefer `dict<_>` over `Dict.t<_>` +symbol: Stdlib.Dict.t +snippet: +```text + 1 | type wrapped = { + 2 | items: Dict.t, +> 3 | names: Stdlib.Dict.t, + | ^^^^^^^^^^^^^ + 4 | } +``` + +severity: warning +rule: preferred-type-syntax +path: src/lint/PreferredTypeSyntax.res +range: 7:22-7:35 +message: Prefer `dict<_>` over `Dict.t<_>` +symbol: Stdlib.Dict.t +snippet: +```text + 6 | let dictValue: Dict.t = dict{} +> 7 | let stdlibDictValue: Stdlib.Dict.t = dict{} + | ^^^^^^^^^^^^^ +``` diff --git a/tests/tools_tests/src/expected/PreferredTypeSyntax.res.lint.json.expected b/tests/tools_tests/src/expected/PreferredTypeSyntax.res.lint.json.expected new file mode 100644 index 00000000000..74d7b0b9d6e --- /dev/null +++ b/tests/tools_tests/src/expected/PreferredTypeSyntax.res.lint.json.expected @@ -0,0 +1 @@ +[{"rule":"preferred-type-syntax","path":"src/lint/PreferredTypeSyntax.res","range":[1,9,1,15],"severity":"warning","message":"Prefer `dict<_>` over `Dict.t<_>`","symbol":"Dict.t"},{"rule":"preferred-type-syntax","path":"src/lint/PreferredTypeSyntax.res","range":[5,15,5,21],"severity":"warning","message":"Prefer `dict<_>` over `Dict.t<_>`","symbol":"Dict.t"},{"rule":"preferred-type-syntax","path":"src/lint/PreferredTypeSyntax.res","range":[2,9,2,22],"severity":"warning","message":"Prefer `dict<_>` over `Dict.t<_>`","symbol":"Stdlib.Dict.t"},{"rule":"preferred-type-syntax","path":"src/lint/PreferredTypeSyntax.res","range":[6,21,6,34],"severity":"warning","message":"Prefer `dict<_>` over `Dict.t<_>`","symbol":"Stdlib.Dict.t"}] diff --git a/tests/tools_tests/src/expected/PreferredTypeSyntax.resi.lint.expected b/tests/tools_tests/src/expected/PreferredTypeSyntax.resi.lint.expected new file mode 100644 index 00000000000..67ff2e71fc0 --- /dev/null +++ b/tests/tools_tests/src/expected/PreferredTypeSyntax.resi.lint.expected @@ -0,0 +1,55 @@ +severity: warning +rule: preferred-type-syntax +path: src/lint/PreferredTypeSyntax.resi +range: 2:10-2:16 +message: Prefer `dict<_>` over `Dict.t<_>` +symbol: Dict.t +snippet: +```text + 1 | type wrapped = { +> 2 | items: Dict.t, + | ^^^^^^ + 3 | names: Stdlib.Dict.t, +``` + +severity: warning +rule: preferred-type-syntax +path: src/lint/PreferredTypeSyntax.resi +range: 6:16-6:22 +message: Prefer `dict<_>` over `Dict.t<_>` +symbol: Dict.t +snippet: +```text + 4 | } +> 6 | let dictValue: Dict.t + | ^^^^^^ + 7 | let stdlibDictValue: Stdlib.Dict.t +``` + +severity: warning +rule: preferred-type-syntax +path: src/lint/PreferredTypeSyntax.resi +range: 3:10-3:23 +message: Prefer `dict<_>` over `Dict.t<_>` +symbol: Stdlib.Dict.t +snippet: +```text + 1 | type wrapped = { + 2 | items: Dict.t, +> 3 | names: Stdlib.Dict.t, + | ^^^^^^^^^^^^^ + 4 | } +``` + +severity: warning +rule: preferred-type-syntax +path: src/lint/PreferredTypeSyntax.resi +range: 7:22-7:35 +message: Prefer `dict<_>` over `Dict.t<_>` +symbol: Stdlib.Dict.t +snippet: +```text + 6 | let dictValue: Dict.t +> 7 | let stdlibDictValue: Stdlib.Dict.t + | ^^^^^^^^^^^^^ +``` diff --git a/tests/tools_tests/src/expected/PreferredTypeSyntax.resi.lint.json.expected b/tests/tools_tests/src/expected/PreferredTypeSyntax.resi.lint.json.expected new file mode 100644 index 00000000000..fbf0d407116 --- /dev/null +++ b/tests/tools_tests/src/expected/PreferredTypeSyntax.resi.lint.json.expected @@ -0,0 +1 @@ +[{"rule":"preferred-type-syntax","path":"src/lint/PreferredTypeSyntax.resi","range":[1,9,1,15],"severity":"warning","message":"Prefer `dict<_>` over `Dict.t<_>`","symbol":"Dict.t"},{"rule":"preferred-type-syntax","path":"src/lint/PreferredTypeSyntax.resi","range":[5,15,5,21],"severity":"warning","message":"Prefer `dict<_>` over `Dict.t<_>`","symbol":"Dict.t"},{"rule":"preferred-type-syntax","path":"src/lint/PreferredTypeSyntax.resi","range":[2,9,2,22],"severity":"warning","message":"Prefer `dict<_>` over `Dict.t<_>`","symbol":"Stdlib.Dict.t"},{"rule":"preferred-type-syntax","path":"src/lint/PreferredTypeSyntax.resi","range":[6,21,6,34],"severity":"warning","message":"Prefer `dict<_>` over `Dict.t<_>`","symbol":"Stdlib.Dict.t"}] diff --git a/tests/tools_tests/src/expected/RewritePreferredTypeSyntax.res.rewrite.diff.expected b/tests/tools_tests/src/expected/RewritePreferredTypeSyntax.res.rewrite.diff.expected new file mode 100644 index 00000000000..5553d466b6e --- /dev/null +++ b/tests/tools_tests/src/expected/RewritePreferredTypeSyntax.res.rewrite.diff.expected @@ -0,0 +1,23 @@ +path: src/rewrite/RewritePreferredTypeSyntax.res +status: preview +rules: +- preferred-type-syntax(4): rewrote `Dict.t<_>` into `dict<_>` +diff: +```diff +--- a/src/rewrite/RewritePreferredTypeSyntax.res ++++ b/src/rewrite/RewritePreferredTypeSyntax.res +@@ -1,7 +1,7 @@ + type wrapped = { +- items: Dict.t, +- names: Stdlib.Dict.t, ++ items: dict, ++ names: dict, + } + +-let dictValue: Dict.t = dict{} +-let stdlibDictValue: Stdlib.Dict.t = dict{} ++let dictValue: dict = dict{} ++let stdlibDictValue: dict = dict{} +``` + +summary: previewed 1 files, unchanged 0, rules: preferred-type-syntax(4) diff --git a/tests/tools_tests/src/expected/RewritePreferredTypeSyntax.res.rewrite.diff.json.expected b/tests/tools_tests/src/expected/RewritePreferredTypeSyntax.res.rewrite.diff.json.expected new file mode 100644 index 00000000000..659e891e1c5 --- /dev/null +++ b/tests/tools_tests/src/expected/RewritePreferredTypeSyntax.res.rewrite.diff.json.expected @@ -0,0 +1 @@ +[{"path":"src/rewrite/RewritePreferredTypeSyntax.res","mode":"diff","changed":true,"applied":[{"rule":"preferred-type-syntax","count":4,"note":"rewrote `Dict.t<_>` into `dict<_>`"}],"diff":"--- a/src/rewrite/RewritePreferredTypeSyntax.res\n+++ b/src/rewrite/RewritePreferredTypeSyntax.res\n@@ -1,7 +1,7 @@\n type wrapped = {\n- items: Dict.t,\n- names: Stdlib.Dict.t,\n+ items: dict,\n+ names: dict,\n }\n\n-let dictValue: Dict.t = dict{}\n-let stdlibDictValue: Stdlib.Dict.t = dict{}\n+let dictValue: dict = dict{}\n+let stdlibDictValue: dict = dict{}","rewritten":"type wrapped = {\n items: dict,\n names: dict,\n}\n\nlet dictValue: dict = dict{}\nlet stdlibDictValue: dict = dict{}\n"}] diff --git a/tests/tools_tests/src/expected/RewritePreferredTypeSyntax.res.rewrite.expected b/tests/tools_tests/src/expected/RewritePreferredTypeSyntax.res.rewrite.expected new file mode 100644 index 00000000000..ea683ec9eae --- /dev/null +++ b/tests/tools_tests/src/expected/RewritePreferredTypeSyntax.res.rewrite.expected @@ -0,0 +1,6 @@ +path: .tmp-rewrite-tests/RewritePreferredTypeSyntax.res +status: rewritten +rules: +- preferred-type-syntax(4): rewrote `Dict.t<_>` into `dict<_>` + +summary: rewritten 1 files, unchanged 0, rules: preferred-type-syntax(4) diff --git a/tests/tools_tests/src/expected/RewritePreferredTypeSyntax.res.rewrite.json.expected b/tests/tools_tests/src/expected/RewritePreferredTypeSyntax.res.rewrite.json.expected new file mode 100644 index 00000000000..8bfa4a32dea --- /dev/null +++ b/tests/tools_tests/src/expected/RewritePreferredTypeSyntax.res.rewrite.json.expected @@ -0,0 +1 @@ +[{"path":".tmp-rewrite-tests/RewritePreferredTypeSyntax.res","mode":"write","changed":true,"applied":[{"rule":"preferred-type-syntax","count":4,"note":"rewrote `Dict.t<_>` into `dict<_>`"}]}] diff --git a/tests/tools_tests/src/expected/RewritePreferredTypeSyntax.res.rewrite.source.expected b/tests/tools_tests/src/expected/RewritePreferredTypeSyntax.res.rewrite.source.expected new file mode 100644 index 00000000000..88830880cee --- /dev/null +++ b/tests/tools_tests/src/expected/RewritePreferredTypeSyntax.res.rewrite.source.expected @@ -0,0 +1,7 @@ +type wrapped = { + items: dict, + names: dict, +} + +let dictValue: dict = dict{} +let stdlibDictValue: dict = dict{} diff --git a/tests/tools_tests/src/expected/RewritePreferredTypeSyntax.resi.rewrite.diff.expected b/tests/tools_tests/src/expected/RewritePreferredTypeSyntax.resi.rewrite.diff.expected new file mode 100644 index 00000000000..03256446d0b --- /dev/null +++ b/tests/tools_tests/src/expected/RewritePreferredTypeSyntax.resi.rewrite.diff.expected @@ -0,0 +1,23 @@ +path: src/rewrite/RewritePreferredTypeSyntax.resi +status: preview +rules: +- preferred-type-syntax(4): rewrote `Dict.t<_>` into `dict<_>` +diff: +```diff +--- a/src/rewrite/RewritePreferredTypeSyntax.resi ++++ b/src/rewrite/RewritePreferredTypeSyntax.resi +@@ -1,7 +1,7 @@ + type wrapped = { +- items: Dict.t, +- names: Stdlib.Dict.t, ++ items: dict, ++ names: dict, + } + +-let dictValue: Dict.t +-let stdlibDictValue: Stdlib.Dict.t ++let dictValue: dict ++let stdlibDictValue: dict +``` + +summary: previewed 1 files, unchanged 0, rules: preferred-type-syntax(4) diff --git a/tests/tools_tests/src/expected/RewritePreferredTypeSyntax.resi.rewrite.diff.json.expected b/tests/tools_tests/src/expected/RewritePreferredTypeSyntax.resi.rewrite.diff.json.expected new file mode 100644 index 00000000000..fa7e5cf6597 --- /dev/null +++ b/tests/tools_tests/src/expected/RewritePreferredTypeSyntax.resi.rewrite.diff.json.expected @@ -0,0 +1 @@ +[{"path":"src/rewrite/RewritePreferredTypeSyntax.resi","mode":"diff","changed":true,"applied":[{"rule":"preferred-type-syntax","count":4,"note":"rewrote `Dict.t<_>` into `dict<_>`"}],"diff":"--- a/src/rewrite/RewritePreferredTypeSyntax.resi\n+++ b/src/rewrite/RewritePreferredTypeSyntax.resi\n@@ -1,7 +1,7 @@\n type wrapped = {\n- items: Dict.t,\n- names: Stdlib.Dict.t,\n+ items: dict,\n+ names: dict,\n }\n\n-let dictValue: Dict.t\n-let stdlibDictValue: Stdlib.Dict.t\n+let dictValue: dict\n+let stdlibDictValue: dict","rewritten":"type wrapped = {\n items: dict,\n names: dict,\n}\n\nlet dictValue: dict\nlet stdlibDictValue: dict\n"}] diff --git a/tests/tools_tests/src/expected/RewritePreferredTypeSyntax.resi.rewrite.expected b/tests/tools_tests/src/expected/RewritePreferredTypeSyntax.resi.rewrite.expected new file mode 100644 index 00000000000..ee2ffb21b27 --- /dev/null +++ b/tests/tools_tests/src/expected/RewritePreferredTypeSyntax.resi.rewrite.expected @@ -0,0 +1,6 @@ +path: .tmp-rewrite-tests/RewritePreferredTypeSyntax.resi +status: rewritten +rules: +- preferred-type-syntax(4): rewrote `Dict.t<_>` into `dict<_>` + +summary: rewritten 1 files, unchanged 0, rules: preferred-type-syntax(4) diff --git a/tests/tools_tests/src/expected/RewritePreferredTypeSyntax.resi.rewrite.json.expected b/tests/tools_tests/src/expected/RewritePreferredTypeSyntax.resi.rewrite.json.expected new file mode 100644 index 00000000000..28fe9195741 --- /dev/null +++ b/tests/tools_tests/src/expected/RewritePreferredTypeSyntax.resi.rewrite.json.expected @@ -0,0 +1 @@ +[{"path":".tmp-rewrite-tests/RewritePreferredTypeSyntax.resi","mode":"write","changed":true,"applied":[{"rule":"preferred-type-syntax","count":4,"note":"rewrote `Dict.t<_>` into `dict<_>`"}]}] diff --git a/tests/tools_tests/src/expected/RewritePreferredTypeSyntax.resi.rewrite.source.expected b/tests/tools_tests/src/expected/RewritePreferredTypeSyntax.resi.rewrite.source.expected new file mode 100644 index 00000000000..ac6deaccb4b --- /dev/null +++ b/tests/tools_tests/src/expected/RewritePreferredTypeSyntax.resi.rewrite.source.expected @@ -0,0 +1,7 @@ +type wrapped = { + items: dict, + names: dict, +} + +let dictValue: dict +let stdlibDictValue: dict diff --git a/tests/tools_tests/src/expected/active-rules.expected b/tests/tools_tests/src/expected/active-rules.expected index 41f2aa3d206..8d21e2a858e 100644 --- a/tests/tools_tests/src/expected/active-rules.expected +++ b/tests/tools_tests/src/expected/active-rules.expected @@ -50,6 +50,17 @@ settings: - severity: warning - message: Use the fully qualified reference directly instead of creating a local alias +namespace: lint +rule: preferred-type-syntax +active: true +summary: Prefer canonical builtin type syntax where available. +details: Currently reports `Dict.t<_>` and `Stdlib.Dict.t<_>` in favor of `dict<_>`. +settings: +- enabled: true +- severity: warning +- message: Prefer `dict<_>` over `Dict.t<_>` +- dict: true + namespace: rewrite rule: prefer-switch active: true @@ -68,4 +79,13 @@ details: Turns `~label=?Some(expr)` into `~label=expr` when the argument is alre settings: - enabled: true -summary: 6 active, 0 inactive, 6 total +namespace: rewrite +rule: preferred-type-syntax +active: true +summary: Rewrite supported types into canonical builtin syntax. +details: Currently rewrites `Dict.t<_>` and `Stdlib.Dict.t<_>` into `dict<_>`. +settings: +- enabled: true +- dict: true + +summary: 8 active, 0 inactive, 8 total diff --git a/tests/tools_tests/src/expected/active-rules.json.expected b/tests/tools_tests/src/expected/active-rules.json.expected index fbcc2f8231f..afeb13f8b10 100644 --- a/tests/tools_tests/src/expected/active-rules.json.expected +++ b/tests/tools_tests/src/expected/active-rules.json.expected @@ -1 +1 @@ -{"rules":[{"namespace":"lint","rule":"forbidden-reference","instance":1,"active":true,"summary":"Report references to configured modules, values, and types.","details":"Uses typed references when available so opened modules and aliases resolve to the real symbol path before matching.","settings":{"enabled":true,"severity":"error","message":"Do not use Belt.Array helpers here. Prefer Stdlib/Array directly.","item[1].kind":"module","item[1].path":"Belt.Array","item[1].message":"Avoid Belt.Array module references here.","item[2].kind":"value","item[2].path":"Belt.Array.forEach","item[3].kind":"value","item[3].path":"Belt.Array.map","item[3].message":"Prefer Array.map directly."}},{"namespace":"lint","rule":"forbidden-reference","instance":2,"active":true,"summary":"Report references to configured modules, values, and types.","details":"Uses typed references when available so opened modules and aliases resolve to the real symbol path before matching.","settings":{"enabled":true,"severity":"warning","item[1].kind":"type","item[1].path":"Js.Json.t","item[1].message":"Avoid Js.Json.t here."}},{"namespace":"lint","rule":"single-use-function","active":true,"summary":"Report local non-exported functions that are defined once and used once.","details":"Counts local function bindings and same-file typed references, then reports helpers that only have a single real use site.","settings":{"enabled":true,"severity":"warning","message":"Inline this helper unless it is a meaningful reusable abstraction."}},{"namespace":"lint","rule":"alias-avoidance","active":true,"summary":"Report local aliases that only shorten an existing qualified value, type, or module reference.","details":"Flags pass-through aliases such as `let alias = Module.value`, `type alias = Module.t`, and `module Alias = Long.Module.Path` so the qualified reference can be used directly instead.","settings":{"enabled":true,"severity":"warning","message":"Use the fully qualified reference directly instead of creating a local alias"}},{"namespace":"rewrite","rule":"prefer-switch","active":true,"summary":"Rewrite `if` and ternary control flow into canonical `switch`.","details":"Simple boolean branches become `switch condition`, and `else if` chains collapse into guarded `switch ()` cases.","settings":{"enabled":true,"if":true,"ternary":true}},{"namespace":"rewrite","rule":"no-optional-some","active":true,"summary":"Rewrite redundant optional-argument wrapping from `?Some(expr)` to the direct labeled form.","details":"Turns `~label=?Some(expr)` into `~label=expr` when the argument is already in an optional position.","settings":{"enabled":true}}],"summary":{"active":6,"inactive":0,"total":6}} +{"rules":[{"namespace":"lint","rule":"forbidden-reference","instance":1,"active":true,"summary":"Report references to configured modules, values, and types.","details":"Uses typed references when available so opened modules and aliases resolve to the real symbol path before matching.","settings":{"enabled":true,"severity":"error","message":"Do not use Belt.Array helpers here. Prefer Stdlib/Array directly.","item[1].kind":"module","item[1].path":"Belt.Array","item[1].message":"Avoid Belt.Array module references here.","item[2].kind":"value","item[2].path":"Belt.Array.forEach","item[3].kind":"value","item[3].path":"Belt.Array.map","item[3].message":"Prefer Array.map directly."}},{"namespace":"lint","rule":"forbidden-reference","instance":2,"active":true,"summary":"Report references to configured modules, values, and types.","details":"Uses typed references when available so opened modules and aliases resolve to the real symbol path before matching.","settings":{"enabled":true,"severity":"warning","item[1].kind":"type","item[1].path":"Js.Json.t","item[1].message":"Avoid Js.Json.t here."}},{"namespace":"lint","rule":"single-use-function","active":true,"summary":"Report local non-exported functions that are defined once and used once.","details":"Counts local function bindings and same-file typed references, then reports helpers that only have a single real use site.","settings":{"enabled":true,"severity":"warning","message":"Inline this helper unless it is a meaningful reusable abstraction."}},{"namespace":"lint","rule":"alias-avoidance","active":true,"summary":"Report local aliases that only shorten an existing qualified value, type, or module reference.","details":"Flags pass-through aliases such as `let alias = Module.value`, `type alias = Module.t`, and `module Alias = Long.Module.Path` so the qualified reference can be used directly instead.","settings":{"enabled":true,"severity":"warning","message":"Use the fully qualified reference directly instead of creating a local alias"}},{"namespace":"lint","rule":"preferred-type-syntax","active":true,"summary":"Prefer canonical builtin type syntax where available.","details":"Currently reports `Dict.t<_>` and `Stdlib.Dict.t<_>` in favor of `dict<_>`.","settings":{"enabled":true,"severity":"warning","message":"Prefer `dict<_>` over `Dict.t<_>`","dict":true}},{"namespace":"rewrite","rule":"prefer-switch","active":true,"summary":"Rewrite `if` and ternary control flow into canonical `switch`.","details":"Simple boolean branches become `switch condition`, and `else if` chains collapse into guarded `switch ()` cases.","settings":{"enabled":true,"if":true,"ternary":true}},{"namespace":"rewrite","rule":"no-optional-some","active":true,"summary":"Rewrite redundant optional-argument wrapping from `?Some(expr)` to the direct labeled form.","details":"Turns `~label=?Some(expr)` into `~label=expr` when the argument is already in an optional position.","settings":{"enabled":true}},{"namespace":"rewrite","rule":"preferred-type-syntax","active":true,"summary":"Rewrite supported types into canonical builtin syntax.","details":"Currently rewrites `Dict.t<_>` and `Stdlib.Dict.t<_>` into `dict<_>`.","settings":{"enabled":true,"dict":true}}],"summary":{"active":8,"inactive":0,"total":8}} diff --git a/tests/tools_tests/src/expected/lint-root.expected b/tests/tools_tests/src/expected/lint-root.expected index bbf63089885..8c8457fc36b 100644 --- a/tests/tools_tests/src/expected/lint-root.expected +++ b/tests/tools_tests/src/expected/lint-root.expected @@ -122,6 +122,118 @@ snippet: | ^ ``` +severity: warning +rule: preferred-type-syntax +path: src/lint/PreferredTypeSyntax.res +range: 2:10-2:16 +message: Prefer `dict<_>` over `Dict.t<_>` +symbol: Dict.t +snippet: +```text + 1 | type wrapped = { +> 2 | items: Dict.t, + | ^^^^^^ + 3 | names: Stdlib.Dict.t, +``` + +severity: warning +rule: preferred-type-syntax +path: src/lint/PreferredTypeSyntax.res +range: 6:16-6:22 +message: Prefer `dict<_>` over `Dict.t<_>` +symbol: Dict.t +snippet: +```text + 4 | } +> 6 | let dictValue: Dict.t = dict{} + | ^^^^^^ + 7 | let stdlibDictValue: Stdlib.Dict.t = dict{} +``` + +severity: warning +rule: preferred-type-syntax +path: src/lint/PreferredTypeSyntax.res +range: 3:10-3:23 +message: Prefer `dict<_>` over `Dict.t<_>` +symbol: Stdlib.Dict.t +snippet: +```text + 1 | type wrapped = { + 2 | items: Dict.t, +> 3 | names: Stdlib.Dict.t, + | ^^^^^^^^^^^^^ + 4 | } +``` + +severity: warning +rule: preferred-type-syntax +path: src/lint/PreferredTypeSyntax.res +range: 7:22-7:35 +message: Prefer `dict<_>` over `Dict.t<_>` +symbol: Stdlib.Dict.t +snippet: +```text + 6 | let dictValue: Dict.t = dict{} +> 7 | let stdlibDictValue: Stdlib.Dict.t = dict{} + | ^^^^^^^^^^^^^ +``` + +severity: warning +rule: preferred-type-syntax +path: src/lint/PreferredTypeSyntax.resi +range: 2:10-2:16 +message: Prefer `dict<_>` over `Dict.t<_>` +symbol: Dict.t +snippet: +```text + 1 | type wrapped = { +> 2 | items: Dict.t, + | ^^^^^^ + 3 | names: Stdlib.Dict.t, +``` + +severity: warning +rule: preferred-type-syntax +path: src/lint/PreferredTypeSyntax.resi +range: 6:16-6:22 +message: Prefer `dict<_>` over `Dict.t<_>` +symbol: Dict.t +snippet: +```text + 4 | } +> 6 | let dictValue: Dict.t + | ^^^^^^ + 7 | let stdlibDictValue: Stdlib.Dict.t +``` + +severity: warning +rule: preferred-type-syntax +path: src/lint/PreferredTypeSyntax.resi +range: 3:10-3:23 +message: Prefer `dict<_>` over `Dict.t<_>` +symbol: Stdlib.Dict.t +snippet: +```text + 1 | type wrapped = { + 2 | items: Dict.t, +> 3 | names: Stdlib.Dict.t, + | ^^^^^^^^^^^^^ + 4 | } +``` + +severity: warning +rule: preferred-type-syntax +path: src/lint/PreferredTypeSyntax.resi +range: 7:22-7:35 +message: Prefer `dict<_>` over `Dict.t<_>` +symbol: Stdlib.Dict.t +snippet: +```text + 6 | let dictValue: Dict.t +> 7 | let stdlibDictValue: Stdlib.Dict.t + | ^^^^^^^^^^^^^ +``` + severity: warning rule: single-use-function path: src/lint/SingleUse.res diff --git a/tests/tools_tests/src/expected/lint-root.json.expected b/tests/tools_tests/src/expected/lint-root.json.expected index 546b01741e4..2e52f1afa02 100644 --- a/tests/tools_tests/src/expected/lint-root.json.expected +++ b/tests/tools_tests/src/expected/lint-root.json.expected @@ -1 +1 @@ -[{"rule":"alias-avoidance","path":"src/lint/AliasAvoidance.res","range":[0,7,0,20],"severity":"warning","message":"Use the fully qualified reference directly instead of creating a local alias","symbol":"ShowFixture.Nested"},{"rule":"alias-avoidance","path":"src/lint/AliasAvoidance.res","range":[2,5,2,14],"severity":"warning","message":"Use the fully qualified reference directly instead of creating a local alias","symbol":"ShowFixture.item"},{"rule":"alias-avoidance","path":"src/lint/AliasAvoidance.res","range":[4,4,4,7],"severity":"warning","message":"Use the fully qualified reference directly instead of creating a local alias","symbol":"String.length"},{"rule":"alias-avoidance","path":"src/lint/AliasAvoidance.resi","range":[0,7,0,20],"severity":"warning","message":"Use the fully qualified reference directly instead of creating a local alias","symbol":"ShowFixture.Nested"},{"rule":"alias-avoidance","path":"src/lint/AliasAvoidance.resi","range":[2,5,2,14],"severity":"warning","message":"Use the fully qualified reference directly instead of creating a local alias","symbol":"ShowFixture.item"},{"rule":"forbidden-reference","path":"src/lint/ForbiddenExplicit.res","range":[0,27,0,34],"severity":"error","message":"Do not use Belt.Array helpers here. Prefer Stdlib/Array directly.","symbol":"Belt_Array.forEach"},{"rule":"forbidden-reference","path":"src/lint/ForbiddenModule.res","range":[0,22,0,28],"severity":"error","message":"Avoid Belt.Array module references here.","symbol":"Belt_Array.length"},{"rule":"forbidden-reference","path":"src/lint/ForbiddenOpen.res","range":[2,16,2,19],"severity":"error","message":"Prefer Array.map directly.","symbol":"Belt_Array.map"},{"rule":"alias-avoidance","path":"src/lint/ForbiddenType.res","range":[0,5,0,9],"severity":"warning","message":"Use the fully qualified reference directly instead of creating a local alias","symbol":"Js.Json.t"},{"rule":"forbidden-reference","path":"src/lint/ForbiddenType.res","range":[0,20,0,21],"severity":"warning","message":"Avoid Js.Json.t here.","symbol":"Js_json.t"},{"rule":"single-use-function","path":"src/lint/SingleUse.res","range":[1,6,1,12],"severity":"warning","message":"Inline this helper unless it is a meaningful reusable abstraction.","symbol":"helper"}] +[{"rule":"alias-avoidance","path":"src/lint/AliasAvoidance.res","range":[0,7,0,20],"severity":"warning","message":"Use the fully qualified reference directly instead of creating a local alias","symbol":"ShowFixture.Nested"},{"rule":"alias-avoidance","path":"src/lint/AliasAvoidance.res","range":[2,5,2,14],"severity":"warning","message":"Use the fully qualified reference directly instead of creating a local alias","symbol":"ShowFixture.item"},{"rule":"alias-avoidance","path":"src/lint/AliasAvoidance.res","range":[4,4,4,7],"severity":"warning","message":"Use the fully qualified reference directly instead of creating a local alias","symbol":"String.length"},{"rule":"alias-avoidance","path":"src/lint/AliasAvoidance.resi","range":[0,7,0,20],"severity":"warning","message":"Use the fully qualified reference directly instead of creating a local alias","symbol":"ShowFixture.Nested"},{"rule":"alias-avoidance","path":"src/lint/AliasAvoidance.resi","range":[2,5,2,14],"severity":"warning","message":"Use the fully qualified reference directly instead of creating a local alias","symbol":"ShowFixture.item"},{"rule":"forbidden-reference","path":"src/lint/ForbiddenExplicit.res","range":[0,27,0,34],"severity":"error","message":"Do not use Belt.Array helpers here. Prefer Stdlib/Array directly.","symbol":"Belt_Array.forEach"},{"rule":"forbidden-reference","path":"src/lint/ForbiddenModule.res","range":[0,22,0,28],"severity":"error","message":"Avoid Belt.Array module references here.","symbol":"Belt_Array.length"},{"rule":"forbidden-reference","path":"src/lint/ForbiddenOpen.res","range":[2,16,2,19],"severity":"error","message":"Prefer Array.map directly.","symbol":"Belt_Array.map"},{"rule":"alias-avoidance","path":"src/lint/ForbiddenType.res","range":[0,5,0,9],"severity":"warning","message":"Use the fully qualified reference directly instead of creating a local alias","symbol":"Js.Json.t"},{"rule":"forbidden-reference","path":"src/lint/ForbiddenType.res","range":[0,20,0,21],"severity":"warning","message":"Avoid Js.Json.t here.","symbol":"Js_json.t"},{"rule":"preferred-type-syntax","path":"src/lint/PreferredTypeSyntax.res","range":[1,9,1,15],"severity":"warning","message":"Prefer `dict<_>` over `Dict.t<_>`","symbol":"Dict.t"},{"rule":"preferred-type-syntax","path":"src/lint/PreferredTypeSyntax.res","range":[5,15,5,21],"severity":"warning","message":"Prefer `dict<_>` over `Dict.t<_>`","symbol":"Dict.t"},{"rule":"preferred-type-syntax","path":"src/lint/PreferredTypeSyntax.res","range":[2,9,2,22],"severity":"warning","message":"Prefer `dict<_>` over `Dict.t<_>`","symbol":"Stdlib.Dict.t"},{"rule":"preferred-type-syntax","path":"src/lint/PreferredTypeSyntax.res","range":[6,21,6,34],"severity":"warning","message":"Prefer `dict<_>` over `Dict.t<_>`","symbol":"Stdlib.Dict.t"},{"rule":"preferred-type-syntax","path":"src/lint/PreferredTypeSyntax.resi","range":[1,9,1,15],"severity":"warning","message":"Prefer `dict<_>` over `Dict.t<_>`","symbol":"Dict.t"},{"rule":"preferred-type-syntax","path":"src/lint/PreferredTypeSyntax.resi","range":[5,15,5,21],"severity":"warning","message":"Prefer `dict<_>` over `Dict.t<_>`","symbol":"Dict.t"},{"rule":"preferred-type-syntax","path":"src/lint/PreferredTypeSyntax.resi","range":[2,9,2,22],"severity":"warning","message":"Prefer `dict<_>` over `Dict.t<_>`","symbol":"Stdlib.Dict.t"},{"rule":"preferred-type-syntax","path":"src/lint/PreferredTypeSyntax.resi","range":[6,21,6,34],"severity":"warning","message":"Prefer `dict<_>` over `Dict.t<_>`","symbol":"Stdlib.Dict.t"},{"rule":"single-use-function","path":"src/lint/SingleUse.res","range":[1,6,1,12],"severity":"warning","message":"Inline this helper unless it is a meaningful reusable abstraction.","symbol":"helper"}] diff --git a/tests/tools_tests/src/expected/rewrite-root.diff.expected b/tests/tools_tests/src/expected/rewrite-root.diff.expected index a2821d6c33b..cd3444e2827 100644 --- a/tests/tools_tests/src/expected/rewrite-root.diff.expected +++ b/tests/tools_tests/src/expected/rewrite-root.diff.expected @@ -72,4 +72,48 @@ diff: +let fromComputation = useValue(~value=String.length("abc"), ()) ``` -summary: previewed 2 files, unchanged 1, rules: prefer-switch(5), no-optional-some(3) +path: src/rewrite/RewritePreferredTypeSyntax.res +status: preview +rules: +- preferred-type-syntax(4): rewrote `Dict.t<_>` into `dict<_>` +diff: +```diff +--- a/src/rewrite/RewritePreferredTypeSyntax.res ++++ b/src/rewrite/RewritePreferredTypeSyntax.res +@@ -1,7 +1,7 @@ + type wrapped = { +- items: Dict.t, +- names: Stdlib.Dict.t, ++ items: dict, ++ names: dict, + } + +-let dictValue: Dict.t = dict{} +-let stdlibDictValue: Stdlib.Dict.t = dict{} ++let dictValue: dict = dict{} ++let stdlibDictValue: dict = dict{} +``` + +path: src/rewrite/RewritePreferredTypeSyntax.resi +status: preview +rules: +- preferred-type-syntax(4): rewrote `Dict.t<_>` into `dict<_>` +diff: +```diff +--- a/src/rewrite/RewritePreferredTypeSyntax.resi ++++ b/src/rewrite/RewritePreferredTypeSyntax.resi +@@ -1,7 +1,7 @@ + type wrapped = { +- items: Dict.t, +- names: Stdlib.Dict.t, ++ items: dict, ++ names: dict, + } + +-let dictValue: Dict.t +-let stdlibDictValue: Stdlib.Dict.t ++let dictValue: dict ++let stdlibDictValue: dict +``` + +summary: previewed 4 files, unchanged 1, rules: prefer-switch(5), no-optional-some(3), preferred-type-syntax(8) diff --git a/tests/tools_tests/src/expected/rewrite-root.diff.json.expected b/tests/tools_tests/src/expected/rewrite-root.diff.json.expected index b18b5f04a57..9d253d8e647 100644 --- a/tests/tools_tests/src/expected/rewrite-root.diff.json.expected +++ b/tests/tools_tests/src/expected/rewrite-root.diff.json.expected @@ -1 +1 @@ -[{"path":"src/rewrite/RewriteClean.res","mode":"diff","changed":false,"applied":[]},{"path":"src/rewrite/RewriteIfs.res","mode":"diff","changed":true,"applied":[{"rule":"prefer-switch","count":4,"note":"rewrote `if` / ternary branches into `switch`"}],"diff":"--- a/src/rewrite/RewriteIfs.res\n+++ b/src/rewrite/RewriteIfs.res\n@@ -1,16 +1,22 @@\n let flag = true\n let other = false\n\n-let direct = if flag { 1 } else { 2 }\n-let ternary = flag ? \"yes\" : \"no\"\n+let direct = switch flag {\n+| true => 1\n+| false => 2\n+}\n+let ternary = switch flag {\n+| true => \"yes\"\n+| false => \"no\"\n+}\n\n-let chained =\n- if flag {\n- 1\n- } else if other {\n- 2\n- } else {\n- 3\n- }\n+let chained = switch () {\n+| _ if flag => 1\n+| _ if other => 2\n+| _ => 3\n+}\n\n-let onlyWhen = if flag { Console.log(\"hit\") }\n+let onlyWhen = switch flag {\n+| true => Console.log(\"hit\")\n+| false => ()\n+}","rewritten":"let flag = true\nlet other = false\n\nlet direct = switch flag {\n| true => 1\n| false => 2\n}\nlet ternary = switch flag {\n| true => \"yes\"\n| false => \"no\"\n}\n\nlet chained = switch () {\n| _ if flag => 1\n| _ if other => 2\n| _ => 3\n}\n\nlet onlyWhen = switch flag {\n| true => Console.log(\"hit\")\n| false => ()\n}\n"},{"path":"src/rewrite/RewriteOptionalSome.res","mode":"diff","changed":true,"applied":[{"rule":"prefer-switch","count":1,"note":"rewrote `if` / ternary branches into `switch`"}, {"rule":"no-optional-some","count":3,"note":"rewrote `~label=?Some(expr)` into `~label=expr`"}],"diff":"--- a/src/rewrite/RewriteOptionalSome.res\n+++ b/src/rewrite/RewriteOptionalSome.res\n@@ -1,6 +1,12 @@\n let flag = true\n let useValue = (~value=?, ()) => value\n\n-let direct = useValue(~value=?Some(1), ())\n-let nested = useValue(~value=?Some(if flag { 1 } else { 2 }), ())\n-let fromComputation = useValue(~value=?Some(String.length(\"abc\")), ())\n+let direct = useValue(~value=1, ())\n+let nested = useValue(\n+ ~value=switch flag {\n+ | true => 1\n+ | false => 2\n+ },\n+ (),\n+)\n+let fromComputation = useValue(~value=String.length(\"abc\"), ())","rewritten":"let flag = true\nlet useValue = (~value=?, ()) => value\n\nlet direct = useValue(~value=1, ())\nlet nested = useValue(\n ~value=switch flag {\n | true => 1\n | false => 2\n },\n (),\n)\nlet fromComputation = useValue(~value=String.length(\"abc\"), ())\n"}] +[{"path":"src/rewrite/RewriteClean.res","mode":"diff","changed":false,"applied":[]},{"path":"src/rewrite/RewriteIfs.res","mode":"diff","changed":true,"applied":[{"rule":"prefer-switch","count":4,"note":"rewrote `if` / ternary branches into `switch`"}],"diff":"--- a/src/rewrite/RewriteIfs.res\n+++ b/src/rewrite/RewriteIfs.res\n@@ -1,16 +1,22 @@\n let flag = true\n let other = false\n\n-let direct = if flag { 1 } else { 2 }\n-let ternary = flag ? \"yes\" : \"no\"\n+let direct = switch flag {\n+| true => 1\n+| false => 2\n+}\n+let ternary = switch flag {\n+| true => \"yes\"\n+| false => \"no\"\n+}\n\n-let chained =\n- if flag {\n- 1\n- } else if other {\n- 2\n- } else {\n- 3\n- }\n+let chained = switch () {\n+| _ if flag => 1\n+| _ if other => 2\n+| _ => 3\n+}\n\n-let onlyWhen = if flag { Console.log(\"hit\") }\n+let onlyWhen = switch flag {\n+| true => Console.log(\"hit\")\n+| false => ()\n+}","rewritten":"let flag = true\nlet other = false\n\nlet direct = switch flag {\n| true => 1\n| false => 2\n}\nlet ternary = switch flag {\n| true => \"yes\"\n| false => \"no\"\n}\n\nlet chained = switch () {\n| _ if flag => 1\n| _ if other => 2\n| _ => 3\n}\n\nlet onlyWhen = switch flag {\n| true => Console.log(\"hit\")\n| false => ()\n}\n"},{"path":"src/rewrite/RewriteOptionalSome.res","mode":"diff","changed":true,"applied":[{"rule":"prefer-switch","count":1,"note":"rewrote `if` / ternary branches into `switch`"}, {"rule":"no-optional-some","count":3,"note":"rewrote `~label=?Some(expr)` into `~label=expr`"}],"diff":"--- a/src/rewrite/RewriteOptionalSome.res\n+++ b/src/rewrite/RewriteOptionalSome.res\n@@ -1,6 +1,12 @@\n let flag = true\n let useValue = (~value=?, ()) => value\n\n-let direct = useValue(~value=?Some(1), ())\n-let nested = useValue(~value=?Some(if flag { 1 } else { 2 }), ())\n-let fromComputation = useValue(~value=?Some(String.length(\"abc\")), ())\n+let direct = useValue(~value=1, ())\n+let nested = useValue(\n+ ~value=switch flag {\n+ | true => 1\n+ | false => 2\n+ },\n+ (),\n+)\n+let fromComputation = useValue(~value=String.length(\"abc\"), ())","rewritten":"let flag = true\nlet useValue = (~value=?, ()) => value\n\nlet direct = useValue(~value=1, ())\nlet nested = useValue(\n ~value=switch flag {\n | true => 1\n | false => 2\n },\n (),\n)\nlet fromComputation = useValue(~value=String.length(\"abc\"), ())\n"},{"path":"src/rewrite/RewritePreferredTypeSyntax.res","mode":"diff","changed":true,"applied":[{"rule":"preferred-type-syntax","count":4,"note":"rewrote `Dict.t<_>` into `dict<_>`"}],"diff":"--- a/src/rewrite/RewritePreferredTypeSyntax.res\n+++ b/src/rewrite/RewritePreferredTypeSyntax.res\n@@ -1,7 +1,7 @@\n type wrapped = {\n- items: Dict.t,\n- names: Stdlib.Dict.t,\n+ items: dict,\n+ names: dict,\n }\n\n-let dictValue: Dict.t = dict{}\n-let stdlibDictValue: Stdlib.Dict.t = dict{}\n+let dictValue: dict = dict{}\n+let stdlibDictValue: dict = dict{}","rewritten":"type wrapped = {\n items: dict,\n names: dict,\n}\n\nlet dictValue: dict = dict{}\nlet stdlibDictValue: dict = dict{}\n"},{"path":"src/rewrite/RewritePreferredTypeSyntax.resi","mode":"diff","changed":true,"applied":[{"rule":"preferred-type-syntax","count":4,"note":"rewrote `Dict.t<_>` into `dict<_>`"}],"diff":"--- a/src/rewrite/RewritePreferredTypeSyntax.resi\n+++ b/src/rewrite/RewritePreferredTypeSyntax.resi\n@@ -1,7 +1,7 @@\n type wrapped = {\n- items: Dict.t,\n- names: Stdlib.Dict.t,\n+ items: dict,\n+ names: dict,\n }\n\n-let dictValue: Dict.t\n-let stdlibDictValue: Stdlib.Dict.t\n+let dictValue: dict\n+let stdlibDictValue: dict","rewritten":"type wrapped = {\n items: dict,\n names: dict,\n}\n\nlet dictValue: dict\nlet stdlibDictValue: dict\n"}] diff --git a/tests/tools_tests/src/expected/rewrite-root.expected b/tests/tools_tests/src/expected/rewrite-root.expected index 68270d05c2c..cec82e11e7f 100644 --- a/tests/tools_tests/src/expected/rewrite-root.expected +++ b/tests/tools_tests/src/expected/rewrite-root.expected @@ -12,4 +12,14 @@ rules: - prefer-switch(1): rewrote `if` / ternary branches into `switch` - no-optional-some(3): rewrote `~label=?Some(expr)` into `~label=expr` -summary: rewritten 2 files, unchanged 1, rules: prefer-switch(5), no-optional-some(3) +path: .tmp-rewrite-tests/root/RewritePreferredTypeSyntax.res +status: rewritten +rules: +- preferred-type-syntax(4): rewrote `Dict.t<_>` into `dict<_>` + +path: .tmp-rewrite-tests/root/RewritePreferredTypeSyntax.resi +status: rewritten +rules: +- preferred-type-syntax(4): rewrote `Dict.t<_>` into `dict<_>` + +summary: rewritten 4 files, unchanged 1, rules: prefer-switch(5), no-optional-some(3), preferred-type-syntax(8) diff --git a/tests/tools_tests/src/expected/rewrite-root.json.expected b/tests/tools_tests/src/expected/rewrite-root.json.expected index a4e90968ab5..f1cd48030c1 100644 --- a/tests/tools_tests/src/expected/rewrite-root.json.expected +++ b/tests/tools_tests/src/expected/rewrite-root.json.expected @@ -1 +1 @@ -[{"path":".tmp-rewrite-tests/root/RewriteClean.res","mode":"write","changed":false,"applied":[]},{"path":".tmp-rewrite-tests/root/RewriteIfs.res","mode":"write","changed":true,"applied":[{"rule":"prefer-switch","count":4,"note":"rewrote `if` / ternary branches into `switch`"}]},{"path":".tmp-rewrite-tests/root/RewriteOptionalSome.res","mode":"write","changed":true,"applied":[{"rule":"prefer-switch","count":1,"note":"rewrote `if` / ternary branches into `switch`"}, {"rule":"no-optional-some","count":3,"note":"rewrote `~label=?Some(expr)` into `~label=expr`"}]}] +[{"path":".tmp-rewrite-tests/root/RewriteClean.res","mode":"write","changed":false,"applied":[]},{"path":".tmp-rewrite-tests/root/RewriteIfs.res","mode":"write","changed":true,"applied":[{"rule":"prefer-switch","count":4,"note":"rewrote `if` / ternary branches into `switch`"}]},{"path":".tmp-rewrite-tests/root/RewriteOptionalSome.res","mode":"write","changed":true,"applied":[{"rule":"prefer-switch","count":1,"note":"rewrote `if` / ternary branches into `switch`"}, {"rule":"no-optional-some","count":3,"note":"rewrote `~label=?Some(expr)` into `~label=expr`"}]},{"path":".tmp-rewrite-tests/root/RewritePreferredTypeSyntax.res","mode":"write","changed":true,"applied":[{"rule":"preferred-type-syntax","count":4,"note":"rewrote `Dict.t<_>` into `dict<_>`"}]},{"path":".tmp-rewrite-tests/root/RewritePreferredTypeSyntax.resi","mode":"write","changed":true,"applied":[{"rule":"preferred-type-syntax","count":4,"note":"rewrote `Dict.t<_>` into `dict<_>`"}]}] diff --git a/tests/tools_tests/src/lint/PreferredTypeSyntax.res b/tests/tools_tests/src/lint/PreferredTypeSyntax.res new file mode 100644 index 00000000000..00595f68a4d --- /dev/null +++ b/tests/tools_tests/src/lint/PreferredTypeSyntax.res @@ -0,0 +1,7 @@ +type wrapped = { + items: Dict.t, + names: Stdlib.Dict.t, +} + +let dictValue: Dict.t = dict{} +let stdlibDictValue: Stdlib.Dict.t = dict{} diff --git a/tests/tools_tests/src/lint/PreferredTypeSyntax.resi b/tests/tools_tests/src/lint/PreferredTypeSyntax.resi new file mode 100644 index 00000000000..1e9b59cb8af --- /dev/null +++ b/tests/tools_tests/src/lint/PreferredTypeSyntax.resi @@ -0,0 +1,7 @@ +type wrapped = { + items: Dict.t, + names: Stdlib.Dict.t, +} + +let dictValue: Dict.t +let stdlibDictValue: Stdlib.Dict.t diff --git a/tests/tools_tests/src/rewrite/RewritePreferredTypeSyntax.res b/tests/tools_tests/src/rewrite/RewritePreferredTypeSyntax.res new file mode 100644 index 00000000000..00595f68a4d --- /dev/null +++ b/tests/tools_tests/src/rewrite/RewritePreferredTypeSyntax.res @@ -0,0 +1,7 @@ +type wrapped = { + items: Dict.t, + names: Stdlib.Dict.t, +} + +let dictValue: Dict.t = dict{} +let stdlibDictValue: Stdlib.Dict.t = dict{} diff --git a/tests/tools_tests/src/rewrite/RewritePreferredTypeSyntax.resi b/tests/tools_tests/src/rewrite/RewritePreferredTypeSyntax.resi new file mode 100644 index 00000000000..1e9b59cb8af --- /dev/null +++ b/tests/tools_tests/src/rewrite/RewritePreferredTypeSyntax.resi @@ -0,0 +1,7 @@ +type wrapped = { + items: Dict.t, + names: Stdlib.Dict.t, +} + +let dictValue: Dict.t +let stdlibDictValue: Stdlib.Dict.t diff --git a/tests/tools_tests/test.sh b/tests/tools_tests/test.sh index 254cc03906f..9bd40b9478f 100755 --- a/tests/tools_tests/test.sh +++ b/tests/tools_tests/test.sh @@ -127,7 +127,7 @@ fi rm -rf .tmp-rewrite-tests mkdir -p .tmp-rewrite-tests -for file in src/rewrite/*.res; do +for file in src/rewrite/*.{res,resi}; do tmp_file=".tmp-rewrite-tests/$(basename $file)" cp "$file" "$tmp_file" @@ -165,7 +165,7 @@ done rm -rf .tmp-rewrite-tests/root mkdir -p .tmp-rewrite-tests/root -cp src/rewrite/*.res .tmp-rewrite-tests/root/ +cp src/rewrite/*.{res,resi} .tmp-rewrite-tests/root/ ../../_build/install/default/bin/rescript-tools rewrite .tmp-rewrite-tests/root > src/expected/rewrite-root.expected if [ "$RUNNER_OS" == "Windows" ]; then perl -pi -e 's/\r\n/\n/g' -- src/expected/rewrite-root.expected @@ -173,7 +173,7 @@ fi rm -rf .tmp-rewrite-tests/root mkdir -p .tmp-rewrite-tests/root -cp src/rewrite/*.res .tmp-rewrite-tests/root/ +cp src/rewrite/*.{res,resi} .tmp-rewrite-tests/root/ ../../_build/install/default/bin/rescript-tools rewrite .tmp-rewrite-tests/root --json > src/expected/rewrite-root.json.expected if [ "$RUNNER_OS" == "Windows" ]; then perl -pi -e 's/\r\n/\n/g' -- src/expected/rewrite-root.json.expected diff --git a/tools/src/lint_analysis.ml b/tools/src/lint_analysis.ml index 9cbce20fdbc..794af0d0385 100644 --- a/tools/src/lint_analysis.ml +++ b/tools/src/lint_analysis.ml @@ -11,10 +11,7 @@ let starts_with_path ~prefix path = in loop prefix path -type forbidden_symbol = { - kind: forbidden_reference_kind; - path: string list; -} +type forbidden_symbol = {kind: forbidden_reference_kind; path: string list} type forbidden_item_match = | ForbiddenItemExact @@ -37,12 +34,14 @@ let forbidden_item_match_is_better candidate best = | ForbiddenItemExact, ForbiddenItemModulePrefix _ -> true | ForbiddenItemModulePrefix _, ForbiddenItemExact -> false | ForbiddenItemExact, ForbiddenItemExact -> false - | ForbiddenItemModulePrefix candidate_length, ForbiddenItemModulePrefix best_length -> + | ( ForbiddenItemModulePrefix candidate_length, + ForbiddenItemModulePrefix best_length ) -> candidate_length > best_length let best_matching_forbidden_item items symbol = items - |> List.fold_left (fun best (item : forbidden_reference_item) -> + |> List.fold_left + (fun best (item : forbidden_reference_item) -> match forbidden_item_match item symbol with | None -> best | Some candidate_match -> ( @@ -110,7 +109,9 @@ let resolve_forbidden_reference_items ~target_path items = match Hashtbl.find_opt resolved_module_cache (path_key path) with | Some resolved -> resolved | None -> - let resolved = Lint_support.SymbolPath.resolve_module_env ~package path in + let resolved = + Lint_support.SymbolPath.resolve_module_env ~package path + in Hashtbl.add resolved_module_cache (path_key path) resolved; resolved in @@ -121,11 +122,12 @@ let resolve_forbidden_reference_items ~target_path items = let module_prefix = take_path prefix_length item.path in match resolve_module_prefix module_prefix with | None -> loop (prefix_length - 1) - | Some env -> + | Some env -> ( let remainder = drop_path prefix_length item.path in match remainder with | [] -> Some [env.QueryEnv.file.moduleName] | _ -> resolve_exported_path ~env ~package ~kind:item.kind remainder + ) in loop (List.length item.path) in @@ -164,7 +166,7 @@ module Ast = struct let rec qualified_ident_path (expression : Parsetree.expression) = match expression.pexp_desc with - | Pexp_ident {txt = (Longident.Ldot _ as lid); _} -> + | Pexp_ident {txt = Longident.Ldot _ as lid; _} -> Some (Utils.flattenLongIdent lid) | Pexp_constraint (expression, _) | Pexp_open (_, _, expression) @@ -174,14 +176,14 @@ module Ast = struct let rec qualified_module_path (module_expr : Parsetree.module_expr) = match module_expr.pmod_desc with - | Pmod_ident {txt = (Longident.Ldot _ as lid); _} -> + | Pmod_ident {txt = Longident.Ldot _ as lid; _} -> Some (Utils.flattenLongIdent lid) | Pmod_constraint (module_expr, _) -> qualified_module_path module_expr | _ -> None let qualified_type_path (core_type : Parsetree.core_type) = match core_type.ptyp_desc with - | Ptyp_constr ({txt = (Longident.Ldot _ as lid); _}, _arguments) -> + | Ptyp_constr ({txt = Longident.Ldot _ as lid; _}, _arguments) -> Some (Utils.flattenLongIdent lid) | _ -> None @@ -190,7 +192,8 @@ module Ast = struct let symbol = Some (String.concat "." symbol_path) in raw_finding ~rule:"alias-avoidance" ~abs_path:path ~loc ~severity:rule.severity - ~message:(effective_alias_avoidance_message rule) ?symbol () + ~message:(effective_alias_avoidance_message rule) + ?symbol () let value_alias_avoidance_findings ~path (rule : alias_avoidance_rule) bindings = @@ -213,10 +216,10 @@ module Ast = struct symbol_path) let module_declaration_alias_avoidance_finding ~path - (rule : alias_avoidance_rule) (module_declaration : Parsetree.module_declaration) - = + (rule : alias_avoidance_rule) + (module_declaration : Parsetree.module_declaration) = match module_declaration.pmd_type.pmty_desc with - | Pmty_alias {txt = (Longident.Ldot _ as lid); _} -> + | Pmty_alias {txt = Longident.Ldot _ as lid; _} -> Some (alias_avoidance_finding ~path rule ~loc:module_declaration.pmd_name.loc (Utils.flattenLongIdent lid)) @@ -241,9 +244,18 @@ module Ast = struct (alias_avoidance_finding ~path rule ~loc:decl.ptype_name.loc symbol_path))) - let summary_of_file ?alias_avoidance_rule path = + let preferred_type_syntax_finding ~path (rule : preferred_type_syntax_rule) + ~(symbol_path : string list) ~loc = + let symbol = Some (String.concat "." symbol_path) in + raw_finding ~rule:"preferred-type-syntax" ~abs_path:path ~loc + ~severity:rule.severity + ~message:(effective_preferred_type_syntax_message rule) + ?symbol () + + let summary_of_file ?alias_avoidance_rule + ?(preferred_type_syntax_rule : preferred_type_syntax_rule option) path = let local_function_bindings = ref StringSet.empty in - let alias_findings = ref [] in + let ast_findings = ref [] in let inspect_bindings bindings = bindings |> List.iter (fun (binding : Parsetree.value_binding) -> @@ -255,8 +267,8 @@ module Ast = struct match alias_avoidance_rule with | None -> () | Some rule -> - alias_findings := - value_alias_avoidance_findings ~path rule bindings @ !alias_findings + ast_findings := + value_alias_avoidance_findings ~path rule bindings @ !ast_findings in let iterator = let open Ast_iterator in @@ -270,24 +282,27 @@ module Ast = struct match alias_avoidance_rule with | None -> () | Some rule -> - alias_findings := - type_alias_avoidance_findings ~path rule decls - @ !alias_findings) + ast_findings := + type_alias_avoidance_findings ~path rule decls @ !ast_findings + ) | Pstr_module module_binding -> ( match alias_avoidance_rule with | None -> () | Some rule -> ( - match module_alias_avoidance_finding ~path rule module_binding with + match + module_alias_avoidance_finding ~path rule module_binding + with | None -> () - | Some finding -> alias_findings := finding :: !alias_findings)) + | Some finding -> ast_findings := finding :: !ast_findings)) | Pstr_recmodule module_bindings -> ( match alias_avoidance_rule with | None -> () | Some rule -> - alias_findings := + ast_findings := (module_bindings - |> List.filter_map (module_alias_avoidance_finding ~path rule)) - @ !alias_findings) + |> List.filter_map (module_alias_avoidance_finding ~path rule) + ) + @ !ast_findings) | _ -> ()); Ast_iterator.default_iterator.structure_item iter structure_item); signature_item = @@ -297,9 +312,9 @@ module Ast = struct match alias_avoidance_rule with | None -> () | Some rule -> - alias_findings := - type_alias_avoidance_findings ~path rule decls - @ !alias_findings) + ast_findings := + type_alias_avoidance_findings ~path rule decls @ !ast_findings + ) | Psig_module module_declaration -> ( match alias_avoidance_rule with | None -> () @@ -309,18 +324,34 @@ module Ast = struct module_declaration with | None -> () - | Some finding -> alias_findings := finding :: !alias_findings)) + | Some finding -> ast_findings := finding :: !ast_findings)) | Psig_recmodule module_declarations -> ( match alias_avoidance_rule with | None -> () | Some rule -> - alias_findings := + ast_findings := (module_declarations |> List.filter_map (module_declaration_alias_avoidance_finding ~path rule)) - @ !alias_findings) + @ !ast_findings) | _ -> ()); Ast_iterator.default_iterator.signature_item iter signature_item); + typ = + (fun iter (core_type : Parsetree.core_type) -> + (match preferred_type_syntax_rule with + | Some rule when rule.enabled && rule.dict -> ( + match core_type.ptyp_desc with + | Ptyp_constr ({txt = Longident.Ldot _ as lid; loc}, _arguments) + when preferred_type_syntax_dict_path + (Utils.flattenLongIdent lid) -> + ast_findings := + preferred_type_syntax_finding ~path rule + ~symbol_path:(Utils.flattenLongIdent lid) + ~loc + :: !ast_findings + | _ -> ()) + | Some _ | None -> ()); + Ast_iterator.default_iterator.typ iter core_type); expr = (fun iter expression -> (match expression.pexp_desc with @@ -330,10 +361,11 @@ module Ast = struct | None -> () | Some rule -> ( match - local_module_alias_avoidance_finding ~path rule name module_expr + local_module_alias_avoidance_finding ~path rule name + module_expr with | None -> () - | Some finding -> alias_findings := finding :: !alias_findings)) + | Some finding -> ast_findings := finding :: !ast_findings)) | _ -> ()); Ast_iterator.default_iterator.expr iter expression); } @@ -388,7 +420,7 @@ module Ast = struct }) in ( {parse_errors; local_function_bindings = !local_function_bindings}, - List.rev !alias_findings ) + List.rev !ast_findings ) end module Typed = struct @@ -483,7 +515,7 @@ module Typed = struct |> List.filter_map (fun loc_item -> match symbol full loc_item with | None -> None - | Some symbol -> + | Some symbol -> ( match matching_rule symbol with | None -> None | Some (rule, item) -> @@ -491,8 +523,9 @@ module Typed = struct Some (raw_finding ~rule:"forbidden-reference" ~abs_path:path ~loc:loc_item.loc ~severity:rule.severity - ~message:(effective_forbidden_reference_item_message rule item) - ?symbol ())) + ~message: + (effective_forbidden_reference_item_message rule item) + ?symbol ()))) let is_function_type typ = match (Shared.dig typ).desc with @@ -522,8 +555,8 @@ module Typed = struct findings := raw_finding ~rule:"single-use-function" ~abs_path:path ~loc:declared.name.loc ~severity:rule.severity - ~message:(effective_single_use_function_message rule) ?symbol - () + ~message:(effective_single_use_function_message rule) + ?symbol () :: !findings) full.file.stamps; List.rev !findings @@ -613,12 +646,17 @@ let analyze_file ~config path = let alias_avoidance_rule = if config.alias_avoidance.enabled then Some config.alias_avoidance else None in - let ast, alias_avoidance_findings = - Ast.summary_of_file ?alias_avoidance_rule path + let preferred_type_syntax_rule = + if config.preferred_type_syntax.enabled && config.preferred_type_syntax.dict + then Some config.preferred_type_syntax + else None + in + let ast, ast_findings = + Ast.summary_of_file ?alias_avoidance_rule ?preferred_type_syntax_rule path in let findings = ref ast.parse_errors in - if ast.parse_errors = [] then begin - findings := alias_avoidance_findings @ !findings; + if ast.parse_errors = [] then ( + findings := ast_findings @ !findings; match if has_typed_artifact path then Cmt.loadFullCmtFromPath ~path else None with @@ -628,8 +666,7 @@ let analyze_file ~config path = Typed.forbidden_reference_findings ~config ~path full @ Typed.single_use_function_findings ~config ~path ~local_function_bindings:ast.local_function_bindings full - @ !findings - end; + @ !findings); !findings type analyzed_target = {display_base: string; findings: raw_finding list} diff --git a/tools/src/lint_config.ml b/tools/src/lint_config.ml index a6d82155c50..9146c8ba4ee 100644 --- a/tools/src/lint_config.ml +++ b/tools/src/lint_config.ml @@ -10,16 +10,25 @@ let default_single_use_function_rule : single_use_function_rule = let default_alias_avoidance_rule : alias_avoidance_rule = {enabled = true; severity = SeverityWarning; message = None} +let default_preferred_type_syntax_rule : preferred_type_syntax_rule = + {enabled = true; severity = SeverityWarning; message = None; dict = false} + +let default_preferred_type_syntax_rewrite_rule : + preferred_type_syntax_rewrite_rule = + {enabled = true; dict = false} + let default_config = { forbidden_reference = [default_forbidden_reference_rule]; single_use_function = default_single_use_function_rule; alias_avoidance = default_alias_avoidance_rule; + preferred_type_syntax = default_preferred_type_syntax_rule; rewrite = { prefer_switch = {enabled = true; rewrite_if = true; rewrite_ternary = true}; no_optional_some = {enabled = true}; + preferred_type_syntax = default_preferred_type_syntax_rewrite_rule; }; } @@ -41,7 +50,7 @@ let result_all results = let rec loop acc = function | [] -> Ok (List.rev acc) | Ok value :: rest -> loop (value :: acc) rest - | Error _ as error :: _ -> error + | (Error _ as error) :: _ -> error in loop [] results @@ -65,22 +74,18 @@ let parse_rule_objects ~rule = function "error: lint rule `%s` must be an object or array of objects" rule) let parse_singleton_rule ~rule ~default parse json = - Result.bind - (parse_rule_objects ~rule json) - (function - | [] -> Ok default - | [rule_json] -> parse rule_json - | _ -> - Error - (Printf.sprintf - "error: lint rule `%s` does not support multiple instances" rule)) + Result.bind (parse_rule_objects ~rule json) (function + | [] -> Ok default + | [rule_json] -> parse rule_json + | _ -> + Error + (Printf.sprintf + "error: lint rule `%s` does not support multiple instances" rule)) let parse_rule_instances ~rule ~default parse json = - Result.bind - (parse_rule_objects ~rule json) - (function - | [] -> Ok [default] - | rule_jsons -> rule_jsons |> List.map parse |> result_all) + Result.bind (parse_rule_objects ~rule json) (function + | [] -> Ok [default] + | rule_jsons -> rule_jsons |> List.map parse |> result_all) let parse_item_objects ~rule = function | None -> Ok [] @@ -91,13 +96,13 @@ let parse_item_objects ~rule = function | Json.Object _ as item_json -> Ok item_json | _ -> Error - (Printf.sprintf - "error: lint rule `%s` item %d must be an object" rule - (index + 1))) + (Printf.sprintf "error: lint rule `%s` item %d must be an object" + rule (index + 1))) |> result_all | Some _ -> Error - (Printf.sprintf "error: lint rule `%s` field `items` must be an array of objects" rule) + (Printf.sprintf + "error: lint rule `%s` field `items` must be an array of objects" rule) let parse_config_json json = let lint_json = json |> Json.get "lint" in @@ -139,7 +144,8 @@ let parse_config_json json = | None -> Error (Printf.sprintf - "error: lint rule `forbidden-reference` item kind `%s` must be one of `module`, `value`, or `type`" + "error: lint rule `forbidden-reference` item kind `%s` must \ + be one of `module`, `value`, or `type`" kind)) in let path = @@ -154,21 +160,18 @@ let parse_config_json json = in if path = [] then Error - "error: lint rule `forbidden-reference` item path must not be empty" + "error: lint rule `forbidden-reference` item path must not be \ + empty" else Ok path in Result.bind kind (fun kind -> Result.map - (fun path -> - { - kind; - path; - message = parse_rule_message item_json; - }) + (fun path -> {kind; path; message = parse_rule_message item_json}) path) in Result.bind - (parse_item_objects ~rule:"forbidden-reference" (rule |> Json.get "items")) + (parse_item_objects ~rule:"forbidden-reference" + (rule |> Json.get "items")) (fun items_json -> Result.map (fun items -> @@ -181,31 +184,50 @@ let parse_config_json json = ~default:default_forbidden_reference_rule.severity rule; message = parse_rule_message rule; items; - } : forbidden_reference_rule)) + } + : forbidden_reference_rule)) (items_json |> List.map parse_item |> result_all)) in let parse_single_use_function_rule rule : (single_use_function_rule, string) result = Ok ({ - enabled = - Lint_support.Json.bool_with_default ~default:true rule "enabled"; - severity = - parse_rule_severity ~default:default_single_use_function_rule.severity - rule; - message = parse_rule_message rule; - } : single_use_function_rule) + enabled = + Lint_support.Json.bool_with_default ~default:true rule "enabled"; + severity = + parse_rule_severity + ~default:default_single_use_function_rule.severity rule; + message = parse_rule_message rule; + } + : single_use_function_rule) in let parse_alias_avoidance_rule rule : (alias_avoidance_rule, string) result = Ok ({ - enabled = - Lint_support.Json.bool_with_default ~default:true rule "enabled"; - severity = - parse_rule_severity ~default:default_alias_avoidance_rule.severity - rule; - message = parse_rule_message rule; - } : alias_avoidance_rule) + enabled = + Lint_support.Json.bool_with_default ~default:true rule "enabled"; + severity = + parse_rule_severity ~default:default_alias_avoidance_rule.severity + rule; + message = parse_rule_message rule; + } + : alias_avoidance_rule) + in + let parse_preferred_type_syntax_rule rule : + (preferred_type_syntax_rule, string) result = + Ok + ({ + enabled = + Lint_support.Json.bool_with_default ~default:true rule "enabled"; + severity = + parse_rule_severity + ~default:default_preferred_type_syntax_rule.severity rule; + message = parse_rule_message rule; + dict = + Lint_support.Json.bool_with_default + ~default:default_preferred_type_syntax_rule.dict rule "dict"; + } + : preferred_type_syntax_rule) in let prefer_switch = match get_rewrite_rule "prefer-switch" with @@ -232,27 +254,53 @@ let parse_config_json json = } | None -> default_config.rewrite.no_optional_some in + let preferred_type_syntax_rewrite = + match get_rewrite_rule "preferred-type-syntax" with + | Some rule -> + { + enabled = + Lint_support.Json.bool_with_default ~default:true rule "enabled"; + dict = + Lint_support.Json.bool_with_default + ~default:default_preferred_type_syntax_rewrite_rule.dict rule "dict"; + } + | None -> default_config.rewrite.preferred_type_syntax + in Result.bind (parse_rule_instances ~rule:"forbidden-reference" - ~default:default_forbidden_reference_rule - parse_forbidden_reference_rule (get_rule "forbidden-reference")) + ~default:default_forbidden_reference_rule parse_forbidden_reference_rule + (get_rule "forbidden-reference")) (fun forbidden_reference -> Result.bind (parse_singleton_rule ~rule:"single-use-function" ~default:default_single_use_function_rule - parse_single_use_function_rule (get_rule "single-use-function")) + parse_single_use_function_rule + (get_rule "single-use-function")) (fun single_use_function -> - Result.map - (fun alias_avoidance -> - { - forbidden_reference; - single_use_function; - alias_avoidance; - rewrite = {prefer_switch; no_optional_some}; - }) + Result.bind (parse_singleton_rule ~rule:"alias-avoidance" - ~default:default_alias_avoidance_rule - parse_alias_avoidance_rule (get_rule "alias-avoidance")))) + ~default:default_alias_avoidance_rule parse_alias_avoidance_rule + (get_rule "alias-avoidance")) + (fun alias_avoidance -> + Result.bind + (parse_singleton_rule ~rule:"preferred-type-syntax" + ~default:default_preferred_type_syntax_rule + parse_preferred_type_syntax_rule + (get_rule "preferred-type-syntax")) + (fun preferred_type_syntax -> + Ok + { + forbidden_reference; + single_use_function; + alias_avoidance; + preferred_type_syntax; + rewrite = + { + prefer_switch; + no_optional_some; + preferred_type_syntax = preferred_type_syntax_rewrite; + }; + })))) let discover_config_path start_path = let rec loop path = diff --git a/tools/src/lint_shared.ml b/tools/src/lint_shared.ml index d684229c33d..a35d330c3bd 100644 --- a/tools/src/lint_shared.ml +++ b/tools/src/lint_shared.ml @@ -50,6 +50,13 @@ type alias_avoidance_rule = { message: string option; } +type preferred_type_syntax_rule = { + enabled: bool; + severity: severity; + message: string option; + dict: bool; +} + type prefer_switch_rule = { enabled: bool; rewrite_if: bool; @@ -58,15 +65,19 @@ type prefer_switch_rule = { type no_optional_some_rule = {enabled: bool} +type preferred_type_syntax_rewrite_rule = {enabled: bool; dict: bool} + type rewrite_config = { prefer_switch: prefer_switch_rule; no_optional_some: no_optional_some_rule; + preferred_type_syntax: preferred_type_syntax_rewrite_rule; } type config = { forbidden_reference: forbidden_reference_rule list; single_use_function: single_use_function_rule; alias_avoidance: alias_avoidance_rule; + preferred_type_syntax: preferred_type_syntax_rule; rewrite: rewrite_config; } @@ -114,6 +125,11 @@ let single_use_function_default_message = "Local function is only used once" let alias_avoidance_default_message = "Use the fully qualified reference directly instead of creating a local alias" +let preferred_type_syntax_default_message = "Prefer `dict<_>` over `Dict.t<_>`" + +let preferred_type_syntax_dict_path path = + path = ["Dict"; "t"] || path = ["Stdlib"; "Dict"; "t"] + let rule_setting_value_to_text = function | RuleSettingBool value -> string_of_bool value | RuleSettingString value -> value @@ -157,6 +173,17 @@ let alias_avoidance_rule_info = rewrite_note = None; } +let preferred_type_syntax_rule_info = + { + namespace = "lint"; + rule = "preferred-type-syntax"; + summary = "Prefer canonical builtin type syntax where available."; + details = + "Currently reports `Dict.t<_>` and `Stdlib.Dict.t<_>` in favor of \ + `dict<_>`."; + rewrite_note = None; + } + let prefer_switch_rule_info = { namespace = "rewrite"; @@ -181,13 +208,25 @@ let no_optional_some_rule_info = rewrite_note = Some "rewrote `~label=?Some(expr)` into `~label=expr`"; } +let preferred_type_syntax_rewrite_rule_info = + { + namespace = "rewrite"; + rule = "preferred-type-syntax"; + summary = "Rewrite supported types into canonical builtin syntax."; + details = + "Currently rewrites `Dict.t<_>` and `Stdlib.Dict.t<_>` into `dict<_>`."; + rewrite_note = Some "rewrote `Dict.t<_>` into `dict<_>`"; + } + let configurable_rule_infos = [ forbidden_reference_rule_info; single_use_function_rule_info; alias_avoidance_rule_info; + preferred_type_syntax_rule_info; prefer_switch_rule_info; no_optional_some_rule_info; + preferred_type_syntax_rewrite_rule_info; ] let rewrite_rule_infos = @@ -204,18 +243,21 @@ let rewrite_note_for_rule rule = let effective_forbidden_reference_message (rule : forbidden_reference_rule) = Option.value rule.message ~default:forbidden_reference_default_message -let effective_forbidden_reference_item_message - (rule : forbidden_reference_rule) (item : forbidden_reference_item) = - Option.value item.message ~default:(effective_forbidden_reference_message rule) +let effective_forbidden_reference_item_message (rule : forbidden_reference_rule) + (item : forbidden_reference_item) = + Option.value item.message + ~default:(effective_forbidden_reference_message rule) let forbidden_reference_message_settings (rule : forbidden_reference_rule) = let has_item_message = - rule.items |> List.exists (fun (item : forbidden_reference_item) -> - item.message <> None) + rule.items + |> List.exists (fun (item : forbidden_reference_item) -> + item.message <> None) in let has_item_without_message = - rule.items |> List.exists (fun (item : forbidden_reference_item) -> - item.message = None) + rule.items + |> List.exists (fun (item : forbidden_reference_item) -> + item.message = None) in match rule.message with | Some message -> [("message", RuleSettingString message)] @@ -230,6 +272,10 @@ let effective_single_use_function_message (rule : single_use_function_rule) = let effective_alias_avoidance_message (rule : alias_avoidance_rule) = Option.value rule.message ~default:alias_avoidance_default_message +let effective_preferred_type_syntax_message (rule : preferred_type_syntax_rule) + = + Option.value rule.message ~default:preferred_type_syntax_default_message + let rule_listings_of_config (config : config) = let instance_if_many total index = if total > 1 then Some (index + 1) else None @@ -240,15 +286,14 @@ let rule_listings_of_config (config : config) = let item_settings = rule.items |> List.mapi (fun item_index (item : forbidden_reference_item) -> - let prefix = - Printf.sprintf "item[%d]" (item_index + 1) - in + let prefix = Printf.sprintf "item[%d]" (item_index + 1) in let base_settings = [ ( prefix ^ ".kind", RuleSettingString (forbidden_reference_kind_to_string item.kind) ); - (prefix ^ ".path", RuleSettingString (String.concat "." item.path)); + ( prefix ^ ".path", + RuleSettingString (String.concat "." item.path) ); ] in match item.message with @@ -316,8 +361,30 @@ let rule_listings_of_config (config : config) = }; ] in + let preferred_type_syntax_listings = + let rule = config.preferred_type_syntax in + [ + { + namespace = preferred_type_syntax_rule_info.namespace; + rule = preferred_type_syntax_rule_info.rule; + instance = None; + active = rule.enabled && rule.dict; + summary = preferred_type_syntax_rule_info.summary; + details = preferred_type_syntax_rule_info.details; + settings = + [ + ("enabled", RuleSettingBool rule.enabled); + ("severity", RuleSettingString (severity_to_string rule.severity)); + ( "message", + RuleSettingString (effective_preferred_type_syntax_message rule) + ); + ("dict", RuleSettingBool rule.dict); + ]; + }; + ] + in forbidden_reference_listings @ single_use_function_listings - @ alias_avoidance_listings + @ alias_avoidance_listings @ preferred_type_syntax_listings @ [ { namespace = prefer_switch_rule_info.namespace; @@ -347,6 +414,22 @@ let rule_listings_of_config (config : config) = settings = [("enabled", RuleSettingBool config.rewrite.no_optional_some.enabled)]; }; + { + namespace = preferred_type_syntax_rewrite_rule_info.namespace; + rule = preferred_type_syntax_rewrite_rule_info.rule; + instance = None; + active = + config.rewrite.preferred_type_syntax.enabled + && config.rewrite.preferred_type_syntax.dict; + summary = preferred_type_syntax_rewrite_rule_info.summary; + details = preferred_type_syntax_rewrite_rule_info.details; + settings = + [ + ( "enabled", + RuleSettingBool config.rewrite.preferred_type_syntax.enabled ); + ("dict", RuleSettingBool config.rewrite.preferred_type_syntax.dict); + ]; + }; ] let severity_of_string = function diff --git a/tools/src/rewrite.ml b/tools/src/rewrite.ml index ee77752fe6c..b4f67790c56 100644 --- a/tools/src/rewrite.ml +++ b/tools/src/rewrite.ml @@ -23,7 +23,11 @@ type rewritten_file = { type run_result = {output: string; changed_files: int} -type rule_counts = {prefer_switch: int; no_optional_some: int} +type rule_counts = { + prefer_switch: int; + no_optional_some: int; + preferred_type_syntax: int; +} let package_for_path path = let uri = Uri.fromPath path in @@ -125,36 +129,35 @@ let verify_rewritten_source ~path ~contents = Res_driver.parse_interface_from_source ~for_printer:true ~display_filename:path ~source:contents in - if invalid then + if invalid then ( let buf = Buffer.create 256 in let formatter = Format.formatter_of_buffer buf in Res_diagnostics.print_report ~formatter diagnostics contents; Format.pp_print_flush formatter (); Error - (Printf.sprintf - "error: rewrite produced invalid syntax for %s\n%s" path - (Buffer.contents buf)) + (Printf.sprintf "error: rewrite produced invalid syntax for %s\n%s" path + (Buffer.contents buf))) else Ok () else let {Res_driver.invalid; diagnostics; _} = Res_driver.parse_implementation_from_source ~for_printer:true ~display_filename:path ~source:contents in - if invalid then + if invalid then ( let buf = Buffer.create 256 in let formatter = Format.formatter_of_buffer buf in Res_diagnostics.print_report ~formatter diagnostics contents; Format.pp_print_flush formatter (); Error - (Printf.sprintf - "error: rewrite produced invalid syntax for %s\n%s" path - (Buffer.contents buf)) + (Printf.sprintf "error: rewrite produced invalid syntax for %s\n%s" path + (Buffer.contents buf))) else Ok () let applied_rules_of_counts (rule_counts : rule_counts) = [ ("prefer-switch", rule_counts.prefer_switch); ("no-optional-some", rule_counts.no_optional_some); + ("preferred-type-syntax", rule_counts.preferred_type_syntax); ] |> List.filter_map (fun (rule, count) -> if count <= 0 then None @@ -163,11 +166,7 @@ let applied_rules_of_counts (rule_counts : rule_counts) = module Diff = struct type op = Equal of string | Delete of string | Insert of string - type entry = { - op: op; - old_no: int option; - new_no: int option; - } + type entry = {op: op; old_no: int option; new_no: int option} let strip_trailing_cr line = let len = String.length line in @@ -189,29 +188,30 @@ module Diff = struct for before_index = before_len - 1 downto 0 do for after_index = after_len - 1 downto 0 do table.(before_index).(after_index) <- - if before.(before_index) = after.(after_index) then - table.(before_index + 1).(after_index + 1) + 1 - else - max table.(before_index + 1).(after_index) - table.(before_index).(after_index + 1) + (if before.(before_index) = after.(after_index) then + table.(before_index + 1).(after_index + 1) + 1 + else + max + table.(before_index + 1).(after_index) + table.(before_index).(after_index + 1)) done done; let rec loop before_index after_index acc = - if before_index < before_len && after_index < after_len - && before.(before_index) = after.(after_index) + if + before_index < before_len && after_index < after_len + && before.(before_index) = after.(after_index) then loop (before_index + 1) (after_index + 1) (Equal before.(before_index) :: acc) - else if before_index < before_len - && (after_index = after_len - || table.(before_index + 1).(after_index) - >= table.(before_index).(after_index + 1)) + else if + before_index < before_len + && (after_index = after_len + || table.(before_index + 1).(after_index) + >= table.(before_index).(after_index + 1)) then - loop (before_index + 1) after_index - (Delete before.(before_index) :: acc) + loop (before_index + 1) after_index (Delete before.(before_index) :: acc) else if after_index < after_len then - loop before_index (after_index + 1) - (Insert after.(after_index) :: acc) + loop before_index (after_index + 1) (Insert after.(after_index) :: acc) else List.rev acc in loop 0 0 [] @@ -279,8 +279,12 @@ module Diff = struct | None -> 1) let render_hunk entries start finish = - let old_start = line_start entries start finish (fun entry -> entry.old_no) in - let new_start = line_start entries start finish (fun entry -> entry.new_no) in + let old_start = + line_start entries start finish (fun entry -> entry.old_no) + in + let new_start = + line_start entries start finish (fun entry -> entry.new_no) + in let old_count = count_lines entries start finish (fun entry -> entry.old_no) in @@ -292,8 +296,8 @@ module Diff = struct new_count in let lines = - entries - |> Array.to_list |> List.mapi (fun index entry -> (index, entry)) + entries |> Array.to_list + |> List.mapi (fun index entry -> (index, entry)) |> List.filter_map (fun (index, entry) -> if index < start || index > finish then None else @@ -336,27 +340,34 @@ module Diff = struct let render ~path ~before ~after = let entries = build_ops ~before ~after |> annotate in - let has_changes = - entries |> Array.exists is_change - in + let has_changes = entries |> Array.exists is_change in if not has_changes then None else let hunks = collect_hunks entries 3 in let body = - hunks |> List.map (fun (start, finish) -> render_hunk entries start finish) + hunks + |> List.map (fun (start, finish) -> render_hunk entries start finish) in Some (String.concat "\n" - ([ - Printf.sprintf "--- a/%s" path; - Printf.sprintf "+++ b/%s" path; - ] + ([Printf.sprintf "--- a/%s" path; Printf.sprintf "+++ b/%s" path] @ body)) end let rewrite_file ~(config : config) path = let prefer_switch_count = ref 0 in let no_optional_some_count = ref 0 in + let preferred_type_syntax_count = ref 0 in + let rewrite_dict_type (core_type : Parsetree.core_type) + (lid : Longident.t Location.loc) args = + incr preferred_type_syntax_count; + let loc = lid.loc in + { + core_type with + ptyp_desc = + Ptyp_constr (Location.mkloc (Longident.Lident "dict") loc, args); + } + in let rec expr mapper (expression : Parsetree.expression) = match expression.pexp_desc with | Pexp_apply {funct; args; partial; transformed_jsx} -> @@ -367,7 +378,8 @@ let rewrite_file ~(config : config) path = |> List.map (fun ((label, arg) : Asttypes.arg_label * Parsetree.expression) -> match (label, arg.pexp_desc) with - | Asttypes.Optional {txt; loc}, Pexp_construct ({txt = Lident "Some"; _}, Some inner) + | ( Asttypes.Optional {txt; loc}, + Pexp_construct ({txt = Lident "Some"; _}, Some inner) ) when config.rewrite.no_optional_some.enabled -> incr no_optional_some_count; (Asttypes.Labelled {txt; loc}, inner) @@ -377,7 +389,7 @@ let rewrite_file ~(config : config) path = expression with pexp_desc = Pexp_apply {funct; args; partial; transformed_jsx}; } - | Pexp_ifthenelse _ when should_rewrite_if ~config expression -> + | Pexp_ifthenelse _ when should_rewrite_if ~config expression -> ( let rec collect branches (current_expr : Parsetree.expression) = match current_expr.pexp_desc with | Pexp_ifthenelse (condition, then_expr, Some else_expr) @@ -409,25 +421,41 @@ let rewrite_file ~(config : config) path = in let attrs = filter_ternary_attributes expression.pexp_attributes in incr prefer_switch_count; - (match branches with - | [condition, then_expr] -> + match branches with + | [(condition, then_expr)] -> make_boolean_switch ~loc:expression.pexp_loc ~attrs condition then_expr fallback - | _ -> - make_guard_switch ~loc:expression.pexp_loc ~attrs branches fallback) + | _ -> make_guard_switch ~loc:expression.pexp_loc ~attrs branches fallback + ) | _ -> Ast_mapper.default_mapper.expr mapper expression + and typ mapper (core_type : Parsetree.core_type) = + match core_type.ptyp_desc with + | Ptyp_constr (({txt = lid; _} as lid_loc), args) + when config.rewrite.preferred_type_syntax.enabled + && config.rewrite.preferred_type_syntax.dict + && preferred_type_syntax_dict_path (Utils.flattenLongIdent lid) -> + let args = List.map (typ mapper) args in + rewrite_dict_type core_type lid_loc args + | _ -> Ast_mapper.default_mapper.typ mapper core_type in - let mapper = {Ast_mapper.default_mapper with expr} in + let mapper = {Ast_mapper.default_mapper with expr; typ} in let finalize ~source ~contents = let applied_rules = applied_rules_of_counts { prefer_switch = !prefer_switch_count; no_optional_some = !no_optional_some_count; + preferred_type_syntax = !preferred_type_syntax_count; } in if applied_rules = [] then - Ok {changed = false; source_contents = source; contents = source; applied_rules} + Ok + { + changed = false; + source_contents = source; + contents = source; + applied_rules; + } else match verify_rewritten_source ~path ~contents with | Error _ as error -> error @@ -494,7 +522,8 @@ let stringify_applied_rules_text applied_rules = let count_unchanged_files results = results |> List.fold_left - (fun count (result : file_result) -> if result.changed then count else count + 1) + (fun count (result : file_result) -> + if result.changed then count else count + 1) 0 let summarize_applied_rules results = @@ -504,9 +533,11 @@ let summarize_applied_rules results = result.applied_rules |> List.iter (fun (applied_rule : applied_rule) -> let total = - Hashtbl.find_opt totals applied_rule.rule |> Option.value ~default:0 + Hashtbl.find_opt totals applied_rule.rule + |> Option.value ~default:0 in - Hashtbl.replace totals applied_rule.rule (total + applied_rule.count))); + Hashtbl.replace totals applied_rule.rule + (total + applied_rule.count))); rewrite_rule_infos |> List.filter_map (fun (rule_info : rule_info) -> match Hashtbl.find_opt totals rule_info.rule with @@ -527,7 +558,8 @@ let stringify_summary_text ~mode results = let changed_files = results |> List.fold_left - (fun count (result : file_result) -> if result.changed then count + 1 else count) + (fun count (result : file_result) -> + if result.changed then count + 1 else count) 0 in let unchanged_files = count_unchanged_files results in @@ -542,7 +574,8 @@ let stringify_summary_text ~mode results = Printf.sprintf "%s(%d)" applied_rule.rule applied_rule.count)) in Printf.sprintf "summary: %s %d files, unchanged %d%s" - (summary_label_for_mode mode) changed_files unchanged_files rule_summary + (summary_label_for_mode mode) + changed_files unchanged_files rule_summary let stringify_text_file_result ~mode ~display_base (result : file_result) = let path = Lint_support.Path.display ~base:display_base result.abs_path in @@ -558,8 +591,7 @@ let stringify_text_file_result ~mode ~display_base (result : file_result) = in match result.diff with | None -> String.concat "\n" lines - | Some diff -> - String.concat "\n" (lines @ ["diff:"; "```diff"; diff; "```"]) + | Some diff -> String.concat "\n" (lines @ ["diff:"; "```diff"; diff; "```"]) let stringify_json_file_result ~mode ~display_base (result : file_result) = let path = Lint_support.Path.display ~base:display_base result.abs_path in @@ -577,8 +609,7 @@ let render_results ~mode ~json ~display_base results = if json then "[" ^ String.concat "," - (results - |> List.map (stringify_json_file_result ~mode ~display_base)) + (results |> List.map (stringify_json_file_result ~mode ~display_base)) ^ "]" else let sections = @@ -603,13 +634,12 @@ let run ?config_path ?(json = false) ?(mode = Write) target = && not (FindFiles.isSourceFile target) then Error - "File extension not supported. This command accepts .res and .resi \ - files" + "File extension not supported. This command accepts .res and .resi files" else let target = Unix.realpath target in match Lint_config.load ?config_path target with | Error _ as error -> error - | Ok config -> + | Ok config -> ( let files = collect_files target in let display_base = display_base target files in let process_one path = @@ -652,4 +682,4 @@ let run ?config_path ?(json = false) ?(mode = Write) target = (fun count (result : file_result) -> if result.changed then count + 1 else count) 0; - } + }) From 90b5411a85598a7fdfba526f1e14c11ca6961467 Mon Sep 17 00:00:00 2001 From: Gabriel Nordeborn Date: Sun, 19 Apr 2026 19:58:36 +0200 Subject: [PATCH 11/17] format Signed-off-by: Gabriel Nordeborn --- tests/tools_tests/src/rewrite/RewriteIfs.res | 25 +++++++----- .../src/rewrite/RewriteOptionalSome.res | 11 +++++- tools/bin/main.ml | 13 ++++--- tools/src/active_rules.ml | 9 +++-- tools/src/find_references.ml | 33 ++++++++-------- tools/src/lint_support.ml | 8 ++-- tools/src/show.ml | 38 ++++++++++--------- 7 files changed, 79 insertions(+), 58 deletions(-) diff --git a/tests/tools_tests/src/rewrite/RewriteIfs.res b/tests/tools_tests/src/rewrite/RewriteIfs.res index bdcc91eb38a..3ffcd688a08 100644 --- a/tests/tools_tests/src/rewrite/RewriteIfs.res +++ b/tests/tools_tests/src/rewrite/RewriteIfs.res @@ -1,16 +1,21 @@ let flag = true let other = false -let direct = if flag { 1 } else { 2 } +let direct = if flag { + 1 +} else { + 2 +} let ternary = flag ? "yes" : "no" -let chained = - if flag { - 1 - } else if other { - 2 - } else { - 3 - } +let chained = if flag { + 1 +} else if other { + 2 +} else { + 3 +} -let onlyWhen = if flag { Console.log("hit") } +let onlyWhen = if flag { + Console.log("hit") +} diff --git a/tests/tools_tests/src/rewrite/RewriteOptionalSome.res b/tests/tools_tests/src/rewrite/RewriteOptionalSome.res index bf5958b2c21..eeac60acee2 100644 --- a/tests/tools_tests/src/rewrite/RewriteOptionalSome.res +++ b/tests/tools_tests/src/rewrite/RewriteOptionalSome.res @@ -2,5 +2,14 @@ let flag = true let useValue = (~value=?, ()) => value let direct = useValue(~value=?Some(1), ()) -let nested = useValue(~value=?Some(if flag { 1 } else { 2 }), ()) +let nested = useValue( + ~value=?Some( + if flag { + 1 + } else { + 2 + }, + ), + (), +) let fromComputation = useValue(~value=?Some(String.length("abc")), ()) diff --git a/tools/bin/main.ml b/tools/bin/main.ml index 95ebbd6cef6..79db5c5e553 100644 --- a/tools/bin/main.ml +++ b/tools/bin/main.ml @@ -343,7 +343,8 @@ let main () = | None -> Error showHelp) | "--comments" :: value :: rest -> ( match Tools.Show.comments_mode_of_string value with - | Some comments_mode -> parse_args kind context_path comments_mode rest + | Some comments_mode -> + parse_args kind context_path comments_mode rest | None -> Error showHelp) | "--context" :: path :: rest -> parse_args kind (Some path) comments_mode rest @@ -363,9 +364,9 @@ let main () = | "find-references" :: rest -> ( match rest with | ["-h"] | ["--help"] -> logAndExit (Ok findReferencesHelp) - | args -> + | args -> ( let rec parse_args symbol_path kind context_path file_path line col = - function + function | [] -> Ok (symbol_path, kind, context_path, file_path, line, col) | "--kind" :: value :: rest -> ( match Tools.Find_references.symbol_kind_of_string value with @@ -395,7 +396,9 @@ let main () = | Some _ -> Error findReferencesHelp) | _ -> Error findReferencesHelp in - match parse_args None Tools.Find_references.Auto None None None None args with + match + parse_args None Tools.Find_references.Auto None None None None args + with | Error help -> logAndExit (Error help) | Ok (symbol_path, kind, context_path, file_path, line, col) -> ( let query = @@ -416,7 +419,7 @@ let main () = exit 2 | Ok {Tools.Find_references.output; _} -> if output <> "" then print_endline output; - exit 0))) + exit 0)))) | "reanalyze" :: _ -> if Sys.getenv_opt "RESCRIPT_REANALYZE_NO_SERVER" = Some "1" then ( let len = Array.length Sys.argv in diff --git a/tools/src/active_rules.ml b/tools/src/active_rules.ml index ffbab8abb90..159f7993af7 100644 --- a/tools/src/active_rules.ml +++ b/tools/src/active_rules.ml @@ -9,7 +9,8 @@ let stringify_setting_value_json = function | RuleSettingStringList values -> Protocol.array (values |> List.map Protocol.wrapInQuotes) -let stringify_setting_json (key, value) = (key, Some (stringify_setting_value_json value)) +let stringify_setting_json (key, value) = + (key, Some (stringify_setting_value_json value)) let stringify_settings_json settings = Lint_support.Json.stringify_compact_object @@ -86,7 +87,8 @@ let render_summary_text listed_rules = 0 in let inactive = total - active in - Printf.sprintf "summary: %d active, %d inactive, %d total" active inactive total + Printf.sprintf "summary: %d active, %d inactive, %d total" active inactive + total let render ~json listed_rules = if json then @@ -95,8 +97,7 @@ let render ~json listed_rules = ( "rules", Some ("[" - ^ String.concat "," - (listed_rules |> List.map stringify_rule_json) + ^ String.concat "," (listed_rules |> List.map stringify_rule_json) ^ "]") ); ("summary", Some (render_summary_json listed_rules)); ] diff --git a/tools/src/find_references.ml b/tools/src/find_references.ml index 9459c31b9b1..b7334e9abad 100644 --- a/tools/src/find_references.ml +++ b/tools/src/find_references.ml @@ -1,11 +1,7 @@ open Analysis open SharedTypes -type symbol_kind = Lint_support.SymbolKind.t = - | Auto - | Module - | Value - | Type +type symbol_kind = Lint_support.SymbolKind.t = Auto | Module | Value | Type type query = | Symbol of { @@ -39,8 +35,8 @@ let display_base_for_path path = | None -> if Lint_support.Path.is_directory path then path else Filename.dirname path -let raw_reference_of_analysis_reference ({References.uri; locOpt} : - References.references) = +let raw_reference_of_analysis_reference + ({References.uri; locOpt} : References.references) = {abs_path = Uri.toPath uri; loc = locOpt} let compare_loc_opt left right = @@ -52,7 +48,8 @@ let compare_loc_opt left right = compare (Lint_support.Range.of_loc left) (Lint_support.Range.of_loc right) let compare_raw_reference left right = - compare (left.abs_path, left.loc |> Option.map Lint_support.Range.of_loc) + compare + (left.abs_path, left.loc |> Option.map Lint_support.Range.of_loc) (right.abs_path, right.loc |> Option.map Lint_support.Range.of_loc) let sort_and_dedupe references = @@ -123,7 +120,9 @@ let resolve_value_references ~package path = |> Result.map (fun refs -> (Value, refs))) let resolve_type_references ~package path = - match Lint_support.SymbolPath.resolve_exported ~package ~tip:Tip.Type path with + match + Lint_support.SymbolPath.resolve_exported ~package ~tip:Tip.Type path + with | None -> None | Some (env, stamp) -> Some @@ -147,21 +146,23 @@ let resolve_symbol_references ~package ~kind path = (fun () -> resolve_value_references ~package path); (fun () -> resolve_type_references ~package path); ] - | Module -> try_symbol_resolvers [fun () -> resolve_module_references ~package path] - | Value -> try_symbol_resolvers [fun () -> resolve_value_references ~package path] - | Type -> try_symbol_resolvers [fun () -> resolve_type_references ~package path] + | Module -> + try_symbol_resolvers [(fun () -> resolve_module_references ~package path)] + | Value -> + try_symbol_resolvers [(fun () -> resolve_value_references ~package path)] + | Type -> + try_symbol_resolvers [(fun () -> resolve_type_references ~package path)] let resolve_location_references ~file_path ~line ~col = match Cmt.loadFullCmtFromPath ~path:file_path with | None -> - Error - (Printf.sprintf "error: failed to load typed info for %s" file_path) + Error (Printf.sprintf "error: failed to load typed info for %s" file_path) | Some full -> ( match References.getLocItem ~full ~pos:(line - 1, col - 1) ~debug:false with | None -> Error - (Printf.sprintf "error: could not resolve a symbol at %s:%d:%d" file_path - line col) + (Printf.sprintf "error: could not resolve a symbol at %s:%d:%d" + file_path line col) | Some loc_item -> Ok (references_from_loc_item full loc_item)) let snippet_of_reference ~source_cache (reference : raw_reference) = diff --git a/tools/src/lint_support.ml b/tools/src/lint_support.ml index a8ab8284499..f4d46fe54b0 100644 --- a/tools/src/lint_support.ml +++ b/tools/src/lint_support.ml @@ -93,8 +93,9 @@ module SymbolPath = struct match remainder with | [] -> Some (SharedTypes.QueryEnv.fromFile file) | _ -> - resolve_in_env ~env:(SharedTypes.QueryEnv.fromFile file) ~package - remainder)) + resolve_in_env + ~env:(SharedTypes.QueryEnv.fromFile file) + ~package remainder)) let direct_scope ~package path = match path with @@ -114,8 +115,7 @@ module SymbolPath = struct package.SharedTypes.opens |> List.filter_map (fun open_path -> resolve_open_env ~package open_path - |> Option.map (fun env -> - {env; path; top_level_module = None})) + |> Option.map (fun env -> {env; path; top_level_module = None})) let scopes ~package path = match direct_scope ~package path with diff --git a/tools/src/show.ml b/tools/src/show.ml index d863a656794..6242dd3b612 100644 --- a/tools/src/show.ml +++ b/tools/src/show.ml @@ -1,11 +1,7 @@ open Analysis open SharedTypes -type show_kind = Lint_support.SymbolKind.t = - | Auto - | Module - | Value - | Type +type show_kind = Lint_support.SymbolKind.t = Auto | Module | Value | Type type comments_mode = Include | Omit @@ -48,8 +44,8 @@ let normalize_output output = | lines -> lines in output |> String.split_on_char '\n' - |> List.map trim_trailing_horizontal_whitespace |> List.rev - |> drop_trailing_blank_lines_rev |> List.rev |> String.concat "\n" + |> List.map trim_trailing_horizontal_whitespace + |> List.rev |> drop_trailing_blank_lines_rev |> List.rev |> String.concat "\n" let docstring_for_mode ~comments_mode docstring = match comments_mode with @@ -64,7 +60,9 @@ let resolve_module_hover ~package ~comments_mode path = ~docstring:(docstring_for_mode ~comments_mode file.structure.docstring) ~name ~file ~package None | None -> ( - match Lint_support.SymbolPath.resolve_exported ~package ~tip:Tip.Module path with + match + Lint_support.SymbolPath.resolve_exported ~package ~tip:Tip.Module path + with | None -> None | Some (env, stamp) -> ( match Stamps.findModule env.file.stamps stamp with @@ -75,7 +73,9 @@ let resolve_module_hover ~package ~comments_mode path = ~name ~file:env.file ~package (Some declared))) let resolve_value_hover ~package ~comments_mode path = - match Lint_support.SymbolPath.resolve_exported ~package ~tip:Tip.Value path with + match + Lint_support.SymbolPath.resolve_exported ~package ~tip:Tip.Value path + with | None -> None | Some (env, stamp) -> ( match Stamps.findValue env.file.stamps stamp with @@ -84,19 +84,21 @@ let resolve_value_hover ~package ~comments_mode path = Some (Hover.hoverWithExpandedTypes ~file:env.file ~package ~supportsMarkdownLinks:false - ~docstring: - (docstring_for_mode ~comments_mode declared.docstring) + ~docstring:(docstring_for_mode ~comments_mode declared.docstring) declared.item)) let resolve_type_hover ~package path = - match Lint_support.SymbolPath.resolve_exported ~package ~tip:Tip.Type path with + match + Lint_support.SymbolPath.resolve_exported ~package ~tip:Tip.Type path + with | None -> None | Some (env, stamp) -> ( match Stamps.findType env.file.stamps stamp with | None -> None - | Some declared -> + | Some declared -> ( let type_definition = - Markdown.codeBlock (Shared.declToString declared.name.txt declared.item.decl) + Markdown.codeBlock + (Shared.declToString declared.name.txt declared.item.decl) in match declared.item.decl.type_manifest with | None -> Some type_definition @@ -106,8 +108,9 @@ let resolve_type_hover ~package path = typ in match expansion_type with - | `Default -> Some (String.concat "\n" (type_definition :: expanded_types)) - | `InlineType -> Some (String.concat "\n" expanded_types))) + | `Default -> + Some (String.concat "\n" (type_definition :: expanded_types)) + | `InlineType -> Some (String.concat "\n" expanded_types)))) let try_resolvers resolvers = resolvers @@ -156,5 +159,4 @@ let run ?context_path ?(kind = Auto) ?(comments_mode = Include) path = | None -> Error (Printf.sprintf "error: could not resolve %s as %s" - (String.concat "." path) - (show_kind_to_string kind)))) + (String.concat "." path) (show_kind_to_string kind)))) From e3d1097ea31e484480423c5ac06cdf6f4453e334 Mon Sep 17 00:00:00 2001 From: Gabriel Nordeborn Date: Sun, 19 Apr 2026 20:32:24 +0200 Subject: [PATCH 12/17] Add forbidden source-root reference lint rule Signed-off-by: Gabriel Nordeborn --- docs/rescript_ai.md | 42 ++++- tests/tools_tests/.rescript-lint.json | 5 + ...biddenGeneratedReference.res.lint.expected | 54 ++++++ ...nGeneratedReference.res.lint.json.expected | 1 + ...iddenGeneratedReference.resi.lint.expected | 26 +++ ...GeneratedReference.resi.lint.json.expected | 1 + .../GeneratedConsumer.res.lint.expected | 0 .../GeneratedConsumer.res.lint.json.expected | 1 + .../src/expected/active-rules.expected | 14 +- .../src/expected/active-rules.json.expected | 2 +- .../src/expected/lint-root.expected | 82 +++++++++ .../src/expected/lint-root.json.expected | 2 +- .../src/generated/GeneratedConsumer.res | 9 + .../src/generated/GeneratedModel.res | 5 + .../src/lint/ForbiddenGeneratedReference.res | 9 + .../src/lint/ForbiddenGeneratedReference.resi | 5 + tests/tools_tests/test.sh | 13 ++ tools/src/lint_analysis.ml | 169 ++++++++++++++---- tools/src/lint_config.ml | 138 ++++++++++++-- tools/src/lint_shared.ml | 79 +++++++- 20 files changed, 600 insertions(+), 57 deletions(-) create mode 100644 tests/tools_tests/src/expected/ForbiddenGeneratedReference.res.lint.expected create mode 100644 tests/tools_tests/src/expected/ForbiddenGeneratedReference.res.lint.json.expected create mode 100644 tests/tools_tests/src/expected/ForbiddenGeneratedReference.resi.lint.expected create mode 100644 tests/tools_tests/src/expected/ForbiddenGeneratedReference.resi.lint.json.expected create mode 100644 tests/tools_tests/src/expected/GeneratedConsumer.res.lint.expected create mode 100644 tests/tools_tests/src/expected/GeneratedConsumer.res.lint.json.expected create mode 100644 tests/tools_tests/src/generated/GeneratedConsumer.res create mode 100644 tests/tools_tests/src/generated/GeneratedModel.res create mode 100644 tests/tools_tests/src/lint/ForbiddenGeneratedReference.res create mode 100644 tests/tools_tests/src/lint/ForbiddenGeneratedReference.resi diff --git a/docs/rescript_ai.md b/docs/rescript_ai.md index 20f4e108a49..479d8cd3519 100644 --- a/docs/rescript_ai.md +++ b/docs/rescript_ai.md @@ -23,7 +23,7 @@ Commands - `find-references`: Finds references from either a symbol path or a source location. Initial rules -- Lint: `forbidden-reference`, `single-use-function`, `alias-avoidance`, `preferred-type-syntax` +- Lint: `forbidden-reference`, `single-use-function`, `alias-avoidance`, `forbidden-source-root-reference`, `preferred-type-syntax` - Rewrite: `prefer-switch`, `no-optional-some`, `preferred-type-syntax` Support @@ -206,6 +206,11 @@ Example shape: ] } ], + "forbidden-source-root-reference": { + "severity": "error", + "roots": ["src/generated"], + "kinds": ["value", "type"] + }, "single-use-function": { "severity": "warning" } @@ -268,6 +273,41 @@ Likely exclusions for V1: Longer term, this can grow into a reanalyze-style map/merge analysis. +### Forbidden source-root references + +Goal: block references whose declarations come from specific source roots. + +Useful for: + +- generated code under folders like `src/generated` or `src/__generated__` +- code owned by another system that should not be referenced directly +- enforcing a boundary between handwritten and generated modules + +V1 shape: + +- configured as folder roots relative to `.rescript-lint.json` +- typed-only, since it matches the declaration origin path rather than the referenced path text +- supports `value` and `type` kinds +- does not report when the current file is also inside the matching forbidden root +- first matching root wins for message selection + +Example: + +```json +{ + "lint": { + "rules": { + "forbidden-source-root-reference": { + "severity": "error", + "roots": ["src/generated"], + "kinds": ["value", "type"], + "message": "Do not reference generated definitions directly." + } + } + } +} +``` + ## Candidate Lint Rule Ideas This section is intentionally a scratchpad for future rules. diff --git a/tests/tools_tests/.rescript-lint.json b/tests/tools_tests/.rescript-lint.json index 011d2335c9a..a3aca39cbb8 100644 --- a/tests/tools_tests/.rescript-lint.json +++ b/tests/tools_tests/.rescript-lint.json @@ -40,6 +40,11 @@ "alias-avoidance": { "severity": "warning" }, + "forbidden-source-root-reference": { + "severity": "error", + "roots": ["src/generated"], + "kinds": ["value", "type"] + }, "preferred-type-syntax": { "severity": "warning", "dict": true diff --git a/tests/tools_tests/src/expected/ForbiddenGeneratedReference.res.lint.expected b/tests/tools_tests/src/expected/ForbiddenGeneratedReference.res.lint.expected new file mode 100644 index 00000000000..5c517f5e7d8 --- /dev/null +++ b/tests/tools_tests/src/expected/ForbiddenGeneratedReference.res.lint.expected @@ -0,0 +1,54 @@ +severity: error +rule: forbidden-source-root-reference +path: src/lint/ForbiddenGeneratedReference.res +range: 2:24-2:37 +message: Do not reference declarations from `src/generated` directly +symbol: GeneratedModel.generatedType +snippet: +```text + 1 | type wrapper = { +> 2 | item: GeneratedModel.generatedType, + | ^^^^^^^^^^^^^ + 3 | } +``` + +severity: error +rule: forbidden-source-root-reference +path: src/lint/ForbiddenGeneratedReference.res +range: 7:46-7:59 +message: Do not reference declarations from `src/generated` directly +symbol: GeneratedModel.generatedType +snippet: +```text + 5 | let next = GeneratedModel.generatedValue + 1 +> 7 | let acceptGenerated = (value: GeneratedModel.generatedType) => { + | ^^^^^^^^^^^^^ + 8 | next + GeneratedModel.generatedValue + value.id +``` + +severity: error +rule: forbidden-source-root-reference +path: src/lint/ForbiddenGeneratedReference.res +range: 5:27-5:41 +message: Do not reference declarations from `src/generated` directly +symbol: GeneratedModel.generatedValue +snippet: +```text + 3 | } +> 5 | let next = GeneratedModel.generatedValue + 1 + | ^^^^^^^^^^^^^^ +``` + +severity: error +rule: forbidden-source-root-reference +path: src/lint/ForbiddenGeneratedReference.res +range: 8:25-8:39 +message: Do not reference declarations from `src/generated` directly +symbol: GeneratedModel.generatedValue +snippet: +```text + 7 | let acceptGenerated = (value: GeneratedModel.generatedType) => { +> 8 | next + GeneratedModel.generatedValue + value.id + | ^^^^^^^^^^^^^^ + 9 | } +``` diff --git a/tests/tools_tests/src/expected/ForbiddenGeneratedReference.res.lint.json.expected b/tests/tools_tests/src/expected/ForbiddenGeneratedReference.res.lint.json.expected new file mode 100644 index 00000000000..d780cc92819 --- /dev/null +++ b/tests/tools_tests/src/expected/ForbiddenGeneratedReference.res.lint.json.expected @@ -0,0 +1 @@ +[{"rule":"forbidden-source-root-reference","path":"src/lint/ForbiddenGeneratedReference.res","range":[1,23,1,36],"severity":"error","message":"Do not reference declarations from `src/generated` directly","symbol":"GeneratedModel.generatedType"},{"rule":"forbidden-source-root-reference","path":"src/lint/ForbiddenGeneratedReference.res","range":[6,45,6,58],"severity":"error","message":"Do not reference declarations from `src/generated` directly","symbol":"GeneratedModel.generatedType"},{"rule":"forbidden-source-root-reference","path":"src/lint/ForbiddenGeneratedReference.res","range":[4,26,4,40],"severity":"error","message":"Do not reference declarations from `src/generated` directly","symbol":"GeneratedModel.generatedValue"},{"rule":"forbidden-source-root-reference","path":"src/lint/ForbiddenGeneratedReference.res","range":[7,24,7,38],"severity":"error","message":"Do not reference declarations from `src/generated` directly","symbol":"GeneratedModel.generatedValue"}] diff --git a/tests/tools_tests/src/expected/ForbiddenGeneratedReference.resi.lint.expected b/tests/tools_tests/src/expected/ForbiddenGeneratedReference.resi.lint.expected new file mode 100644 index 00000000000..62785bbbe5b --- /dev/null +++ b/tests/tools_tests/src/expected/ForbiddenGeneratedReference.resi.lint.expected @@ -0,0 +1,26 @@ +severity: error +rule: forbidden-source-root-reference +path: src/lint/ForbiddenGeneratedReference.resi +range: 2:24-2:37 +message: Do not reference declarations from `src/generated` directly +symbol: GeneratedModel.generatedType +snippet: +```text + 1 | type wrapper = { +> 2 | item: GeneratedModel.generatedType, + | ^^^^^^^^^^^^^ + 3 | } +``` + +severity: error +rule: forbidden-source-root-reference +path: src/lint/ForbiddenGeneratedReference.resi +range: 5:37-5:50 +message: Do not reference declarations from `src/generated` directly +symbol: GeneratedModel.generatedType +snippet: +```text + 3 | } +> 5 | let acceptGenerated: GeneratedModel.generatedType => int + | ^^^^^^^^^^^^^ +``` diff --git a/tests/tools_tests/src/expected/ForbiddenGeneratedReference.resi.lint.json.expected b/tests/tools_tests/src/expected/ForbiddenGeneratedReference.resi.lint.json.expected new file mode 100644 index 00000000000..d64afb066cd --- /dev/null +++ b/tests/tools_tests/src/expected/ForbiddenGeneratedReference.resi.lint.json.expected @@ -0,0 +1 @@ +[{"rule":"forbidden-source-root-reference","path":"src/lint/ForbiddenGeneratedReference.resi","range":[1,23,1,36],"severity":"error","message":"Do not reference declarations from `src/generated` directly","symbol":"GeneratedModel.generatedType"},{"rule":"forbidden-source-root-reference","path":"src/lint/ForbiddenGeneratedReference.resi","range":[4,36,4,49],"severity":"error","message":"Do not reference declarations from `src/generated` directly","symbol":"GeneratedModel.generatedType"}] diff --git a/tests/tools_tests/src/expected/GeneratedConsumer.res.lint.expected b/tests/tools_tests/src/expected/GeneratedConsumer.res.lint.expected new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/tools_tests/src/expected/GeneratedConsumer.res.lint.json.expected b/tests/tools_tests/src/expected/GeneratedConsumer.res.lint.json.expected new file mode 100644 index 00000000000..fe51488c706 --- /dev/null +++ b/tests/tools_tests/src/expected/GeneratedConsumer.res.lint.json.expected @@ -0,0 +1 @@ +[] diff --git a/tests/tools_tests/src/expected/active-rules.expected b/tests/tools_tests/src/expected/active-rules.expected index 8d21e2a858e..7c128fe49ec 100644 --- a/tests/tools_tests/src/expected/active-rules.expected +++ b/tests/tools_tests/src/expected/active-rules.expected @@ -50,6 +50,18 @@ settings: - severity: warning - message: Use the fully qualified reference directly instead of creating a local alias +namespace: lint +rule: forbidden-source-root-reference +active: true +summary: Report references whose declarations come from configured source roots. +details: Uses typed declaration origin paths so generated or otherwise restricted source trees can be blocked by folder root. +settings: +- enabled: true +- severity: error +- message: Do not reference declarations from `src/generated` directly +- roots: src/generated +- kinds: value, type + namespace: lint rule: preferred-type-syntax active: true @@ -88,4 +100,4 @@ settings: - enabled: true - dict: true -summary: 8 active, 0 inactive, 8 total +summary: 9 active, 0 inactive, 9 total diff --git a/tests/tools_tests/src/expected/active-rules.json.expected b/tests/tools_tests/src/expected/active-rules.json.expected index afeb13f8b10..b9af89f9865 100644 --- a/tests/tools_tests/src/expected/active-rules.json.expected +++ b/tests/tools_tests/src/expected/active-rules.json.expected @@ -1 +1 @@ -{"rules":[{"namespace":"lint","rule":"forbidden-reference","instance":1,"active":true,"summary":"Report references to configured modules, values, and types.","details":"Uses typed references when available so opened modules and aliases resolve to the real symbol path before matching.","settings":{"enabled":true,"severity":"error","message":"Do not use Belt.Array helpers here. Prefer Stdlib/Array directly.","item[1].kind":"module","item[1].path":"Belt.Array","item[1].message":"Avoid Belt.Array module references here.","item[2].kind":"value","item[2].path":"Belt.Array.forEach","item[3].kind":"value","item[3].path":"Belt.Array.map","item[3].message":"Prefer Array.map directly."}},{"namespace":"lint","rule":"forbidden-reference","instance":2,"active":true,"summary":"Report references to configured modules, values, and types.","details":"Uses typed references when available so opened modules and aliases resolve to the real symbol path before matching.","settings":{"enabled":true,"severity":"warning","item[1].kind":"type","item[1].path":"Js.Json.t","item[1].message":"Avoid Js.Json.t here."}},{"namespace":"lint","rule":"single-use-function","active":true,"summary":"Report local non-exported functions that are defined once and used once.","details":"Counts local function bindings and same-file typed references, then reports helpers that only have a single real use site.","settings":{"enabled":true,"severity":"warning","message":"Inline this helper unless it is a meaningful reusable abstraction."}},{"namespace":"lint","rule":"alias-avoidance","active":true,"summary":"Report local aliases that only shorten an existing qualified value, type, or module reference.","details":"Flags pass-through aliases such as `let alias = Module.value`, `type alias = Module.t`, and `module Alias = Long.Module.Path` so the qualified reference can be used directly instead.","settings":{"enabled":true,"severity":"warning","message":"Use the fully qualified reference directly instead of creating a local alias"}},{"namespace":"lint","rule":"preferred-type-syntax","active":true,"summary":"Prefer canonical builtin type syntax where available.","details":"Currently reports `Dict.t<_>` and `Stdlib.Dict.t<_>` in favor of `dict<_>`.","settings":{"enabled":true,"severity":"warning","message":"Prefer `dict<_>` over `Dict.t<_>`","dict":true}},{"namespace":"rewrite","rule":"prefer-switch","active":true,"summary":"Rewrite `if` and ternary control flow into canonical `switch`.","details":"Simple boolean branches become `switch condition`, and `else if` chains collapse into guarded `switch ()` cases.","settings":{"enabled":true,"if":true,"ternary":true}},{"namespace":"rewrite","rule":"no-optional-some","active":true,"summary":"Rewrite redundant optional-argument wrapping from `?Some(expr)` to the direct labeled form.","details":"Turns `~label=?Some(expr)` into `~label=expr` when the argument is already in an optional position.","settings":{"enabled":true}},{"namespace":"rewrite","rule":"preferred-type-syntax","active":true,"summary":"Rewrite supported types into canonical builtin syntax.","details":"Currently rewrites `Dict.t<_>` and `Stdlib.Dict.t<_>` into `dict<_>`.","settings":{"enabled":true,"dict":true}}],"summary":{"active":8,"inactive":0,"total":8}} +{"rules":[{"namespace":"lint","rule":"forbidden-reference","instance":1,"active":true,"summary":"Report references to configured modules, values, and types.","details":"Uses typed references when available so opened modules and aliases resolve to the real symbol path before matching.","settings":{"enabled":true,"severity":"error","message":"Do not use Belt.Array helpers here. Prefer Stdlib/Array directly.","item[1].kind":"module","item[1].path":"Belt.Array","item[1].message":"Avoid Belt.Array module references here.","item[2].kind":"value","item[2].path":"Belt.Array.forEach","item[3].kind":"value","item[3].path":"Belt.Array.map","item[3].message":"Prefer Array.map directly."}},{"namespace":"lint","rule":"forbidden-reference","instance":2,"active":true,"summary":"Report references to configured modules, values, and types.","details":"Uses typed references when available so opened modules and aliases resolve to the real symbol path before matching.","settings":{"enabled":true,"severity":"warning","item[1].kind":"type","item[1].path":"Js.Json.t","item[1].message":"Avoid Js.Json.t here."}},{"namespace":"lint","rule":"single-use-function","active":true,"summary":"Report local non-exported functions that are defined once and used once.","details":"Counts local function bindings and same-file typed references, then reports helpers that only have a single real use site.","settings":{"enabled":true,"severity":"warning","message":"Inline this helper unless it is a meaningful reusable abstraction."}},{"namespace":"lint","rule":"alias-avoidance","active":true,"summary":"Report local aliases that only shorten an existing qualified value, type, or module reference.","details":"Flags pass-through aliases such as `let alias = Module.value`, `type alias = Module.t`, and `module Alias = Long.Module.Path` so the qualified reference can be used directly instead.","settings":{"enabled":true,"severity":"warning","message":"Use the fully qualified reference directly instead of creating a local alias"}},{"namespace":"lint","rule":"forbidden-source-root-reference","active":true,"summary":"Report references whose declarations come from configured source roots.","details":"Uses typed declaration origin paths so generated or otherwise restricted source trees can be blocked by folder root.","settings":{"enabled":true,"severity":"error","message":"Do not reference declarations from `src/generated` directly","roots":["src/generated"],"kinds":["value", "type"]}},{"namespace":"lint","rule":"preferred-type-syntax","active":true,"summary":"Prefer canonical builtin type syntax where available.","details":"Currently reports `Dict.t<_>` and `Stdlib.Dict.t<_>` in favor of `dict<_>`.","settings":{"enabled":true,"severity":"warning","message":"Prefer `dict<_>` over `Dict.t<_>`","dict":true}},{"namespace":"rewrite","rule":"prefer-switch","active":true,"summary":"Rewrite `if` and ternary control flow into canonical `switch`.","details":"Simple boolean branches become `switch condition`, and `else if` chains collapse into guarded `switch ()` cases.","settings":{"enabled":true,"if":true,"ternary":true}},{"namespace":"rewrite","rule":"no-optional-some","active":true,"summary":"Rewrite redundant optional-argument wrapping from `?Some(expr)` to the direct labeled form.","details":"Turns `~label=?Some(expr)` into `~label=expr` when the argument is already in an optional position.","settings":{"enabled":true}},{"namespace":"rewrite","rule":"preferred-type-syntax","active":true,"summary":"Rewrite supported types into canonical builtin syntax.","details":"Currently rewrites `Dict.t<_>` and `Stdlib.Dict.t<_>` into `dict<_>`.","settings":{"enabled":true,"dict":true}}],"summary":{"active":9,"inactive":0,"total":9}} diff --git a/tests/tools_tests/src/expected/lint-root.expected b/tests/tools_tests/src/expected/lint-root.expected index 8c8457fc36b..b46f5f1adf9 100644 --- a/tests/tools_tests/src/expected/lint-root.expected +++ b/tests/tools_tests/src/expected/lint-root.expected @@ -73,6 +73,88 @@ snippet: | ^^^^^^^ ``` +severity: error +rule: forbidden-source-root-reference +path: src/lint/ForbiddenGeneratedReference.res +range: 2:24-2:37 +message: Do not reference declarations from `src/generated` directly +symbol: GeneratedModel.generatedType +snippet: +```text + 1 | type wrapper = { +> 2 | item: GeneratedModel.generatedType, + | ^^^^^^^^^^^^^ + 3 | } +``` + +severity: error +rule: forbidden-source-root-reference +path: src/lint/ForbiddenGeneratedReference.res +range: 7:46-7:59 +message: Do not reference declarations from `src/generated` directly +symbol: GeneratedModel.generatedType +snippet: +```text + 5 | let next = GeneratedModel.generatedValue + 1 +> 7 | let acceptGenerated = (value: GeneratedModel.generatedType) => { + | ^^^^^^^^^^^^^ + 8 | next + GeneratedModel.generatedValue + value.id +``` + +severity: error +rule: forbidden-source-root-reference +path: src/lint/ForbiddenGeneratedReference.res +range: 5:27-5:41 +message: Do not reference declarations from `src/generated` directly +symbol: GeneratedModel.generatedValue +snippet: +```text + 3 | } +> 5 | let next = GeneratedModel.generatedValue + 1 + | ^^^^^^^^^^^^^^ +``` + +severity: error +rule: forbidden-source-root-reference +path: src/lint/ForbiddenGeneratedReference.res +range: 8:25-8:39 +message: Do not reference declarations from `src/generated` directly +symbol: GeneratedModel.generatedValue +snippet: +```text + 7 | let acceptGenerated = (value: GeneratedModel.generatedType) => { +> 8 | next + GeneratedModel.generatedValue + value.id + | ^^^^^^^^^^^^^^ + 9 | } +``` + +severity: error +rule: forbidden-source-root-reference +path: src/lint/ForbiddenGeneratedReference.resi +range: 2:24-2:37 +message: Do not reference declarations from `src/generated` directly +symbol: GeneratedModel.generatedType +snippet: +```text + 1 | type wrapper = { +> 2 | item: GeneratedModel.generatedType, + | ^^^^^^^^^^^^^ + 3 | } +``` + +severity: error +rule: forbidden-source-root-reference +path: src/lint/ForbiddenGeneratedReference.resi +range: 5:37-5:50 +message: Do not reference declarations from `src/generated` directly +symbol: GeneratedModel.generatedType +snippet: +```text + 3 | } +> 5 | let acceptGenerated: GeneratedModel.generatedType => int + | ^^^^^^^^^^^^^ +``` + severity: error rule: forbidden-reference path: src/lint/ForbiddenModule.res diff --git a/tests/tools_tests/src/expected/lint-root.json.expected b/tests/tools_tests/src/expected/lint-root.json.expected index 2e52f1afa02..83d1737bf0a 100644 --- a/tests/tools_tests/src/expected/lint-root.json.expected +++ b/tests/tools_tests/src/expected/lint-root.json.expected @@ -1 +1 @@ -[{"rule":"alias-avoidance","path":"src/lint/AliasAvoidance.res","range":[0,7,0,20],"severity":"warning","message":"Use the fully qualified reference directly instead of creating a local alias","symbol":"ShowFixture.Nested"},{"rule":"alias-avoidance","path":"src/lint/AliasAvoidance.res","range":[2,5,2,14],"severity":"warning","message":"Use the fully qualified reference directly instead of creating a local alias","symbol":"ShowFixture.item"},{"rule":"alias-avoidance","path":"src/lint/AliasAvoidance.res","range":[4,4,4,7],"severity":"warning","message":"Use the fully qualified reference directly instead of creating a local alias","symbol":"String.length"},{"rule":"alias-avoidance","path":"src/lint/AliasAvoidance.resi","range":[0,7,0,20],"severity":"warning","message":"Use the fully qualified reference directly instead of creating a local alias","symbol":"ShowFixture.Nested"},{"rule":"alias-avoidance","path":"src/lint/AliasAvoidance.resi","range":[2,5,2,14],"severity":"warning","message":"Use the fully qualified reference directly instead of creating a local alias","symbol":"ShowFixture.item"},{"rule":"forbidden-reference","path":"src/lint/ForbiddenExplicit.res","range":[0,27,0,34],"severity":"error","message":"Do not use Belt.Array helpers here. Prefer Stdlib/Array directly.","symbol":"Belt_Array.forEach"},{"rule":"forbidden-reference","path":"src/lint/ForbiddenModule.res","range":[0,22,0,28],"severity":"error","message":"Avoid Belt.Array module references here.","symbol":"Belt_Array.length"},{"rule":"forbidden-reference","path":"src/lint/ForbiddenOpen.res","range":[2,16,2,19],"severity":"error","message":"Prefer Array.map directly.","symbol":"Belt_Array.map"},{"rule":"alias-avoidance","path":"src/lint/ForbiddenType.res","range":[0,5,0,9],"severity":"warning","message":"Use the fully qualified reference directly instead of creating a local alias","symbol":"Js.Json.t"},{"rule":"forbidden-reference","path":"src/lint/ForbiddenType.res","range":[0,20,0,21],"severity":"warning","message":"Avoid Js.Json.t here.","symbol":"Js_json.t"},{"rule":"preferred-type-syntax","path":"src/lint/PreferredTypeSyntax.res","range":[1,9,1,15],"severity":"warning","message":"Prefer `dict<_>` over `Dict.t<_>`","symbol":"Dict.t"},{"rule":"preferred-type-syntax","path":"src/lint/PreferredTypeSyntax.res","range":[5,15,5,21],"severity":"warning","message":"Prefer `dict<_>` over `Dict.t<_>`","symbol":"Dict.t"},{"rule":"preferred-type-syntax","path":"src/lint/PreferredTypeSyntax.res","range":[2,9,2,22],"severity":"warning","message":"Prefer `dict<_>` over `Dict.t<_>`","symbol":"Stdlib.Dict.t"},{"rule":"preferred-type-syntax","path":"src/lint/PreferredTypeSyntax.res","range":[6,21,6,34],"severity":"warning","message":"Prefer `dict<_>` over `Dict.t<_>`","symbol":"Stdlib.Dict.t"},{"rule":"preferred-type-syntax","path":"src/lint/PreferredTypeSyntax.resi","range":[1,9,1,15],"severity":"warning","message":"Prefer `dict<_>` over `Dict.t<_>`","symbol":"Dict.t"},{"rule":"preferred-type-syntax","path":"src/lint/PreferredTypeSyntax.resi","range":[5,15,5,21],"severity":"warning","message":"Prefer `dict<_>` over `Dict.t<_>`","symbol":"Dict.t"},{"rule":"preferred-type-syntax","path":"src/lint/PreferredTypeSyntax.resi","range":[2,9,2,22],"severity":"warning","message":"Prefer `dict<_>` over `Dict.t<_>`","symbol":"Stdlib.Dict.t"},{"rule":"preferred-type-syntax","path":"src/lint/PreferredTypeSyntax.resi","range":[6,21,6,34],"severity":"warning","message":"Prefer `dict<_>` over `Dict.t<_>`","symbol":"Stdlib.Dict.t"},{"rule":"single-use-function","path":"src/lint/SingleUse.res","range":[1,6,1,12],"severity":"warning","message":"Inline this helper unless it is a meaningful reusable abstraction.","symbol":"helper"}] +[{"rule":"alias-avoidance","path":"src/lint/AliasAvoidance.res","range":[0,7,0,20],"severity":"warning","message":"Use the fully qualified reference directly instead of creating a local alias","symbol":"ShowFixture.Nested"},{"rule":"alias-avoidance","path":"src/lint/AliasAvoidance.res","range":[2,5,2,14],"severity":"warning","message":"Use the fully qualified reference directly instead of creating a local alias","symbol":"ShowFixture.item"},{"rule":"alias-avoidance","path":"src/lint/AliasAvoidance.res","range":[4,4,4,7],"severity":"warning","message":"Use the fully qualified reference directly instead of creating a local alias","symbol":"String.length"},{"rule":"alias-avoidance","path":"src/lint/AliasAvoidance.resi","range":[0,7,0,20],"severity":"warning","message":"Use the fully qualified reference directly instead of creating a local alias","symbol":"ShowFixture.Nested"},{"rule":"alias-avoidance","path":"src/lint/AliasAvoidance.resi","range":[2,5,2,14],"severity":"warning","message":"Use the fully qualified reference directly instead of creating a local alias","symbol":"ShowFixture.item"},{"rule":"forbidden-reference","path":"src/lint/ForbiddenExplicit.res","range":[0,27,0,34],"severity":"error","message":"Do not use Belt.Array helpers here. Prefer Stdlib/Array directly.","symbol":"Belt_Array.forEach"},{"rule":"forbidden-source-root-reference","path":"src/lint/ForbiddenGeneratedReference.res","range":[1,23,1,36],"severity":"error","message":"Do not reference declarations from `src/generated` directly","symbol":"GeneratedModel.generatedType"},{"rule":"forbidden-source-root-reference","path":"src/lint/ForbiddenGeneratedReference.res","range":[6,45,6,58],"severity":"error","message":"Do not reference declarations from `src/generated` directly","symbol":"GeneratedModel.generatedType"},{"rule":"forbidden-source-root-reference","path":"src/lint/ForbiddenGeneratedReference.res","range":[4,26,4,40],"severity":"error","message":"Do not reference declarations from `src/generated` directly","symbol":"GeneratedModel.generatedValue"},{"rule":"forbidden-source-root-reference","path":"src/lint/ForbiddenGeneratedReference.res","range":[7,24,7,38],"severity":"error","message":"Do not reference declarations from `src/generated` directly","symbol":"GeneratedModel.generatedValue"},{"rule":"forbidden-source-root-reference","path":"src/lint/ForbiddenGeneratedReference.resi","range":[1,23,1,36],"severity":"error","message":"Do not reference declarations from `src/generated` directly","symbol":"GeneratedModel.generatedType"},{"rule":"forbidden-source-root-reference","path":"src/lint/ForbiddenGeneratedReference.resi","range":[4,36,4,49],"severity":"error","message":"Do not reference declarations from `src/generated` directly","symbol":"GeneratedModel.generatedType"},{"rule":"forbidden-reference","path":"src/lint/ForbiddenModule.res","range":[0,22,0,28],"severity":"error","message":"Avoid Belt.Array module references here.","symbol":"Belt_Array.length"},{"rule":"forbidden-reference","path":"src/lint/ForbiddenOpen.res","range":[2,16,2,19],"severity":"error","message":"Prefer Array.map directly.","symbol":"Belt_Array.map"},{"rule":"alias-avoidance","path":"src/lint/ForbiddenType.res","range":[0,5,0,9],"severity":"warning","message":"Use the fully qualified reference directly instead of creating a local alias","symbol":"Js.Json.t"},{"rule":"forbidden-reference","path":"src/lint/ForbiddenType.res","range":[0,20,0,21],"severity":"warning","message":"Avoid Js.Json.t here.","symbol":"Js_json.t"},{"rule":"preferred-type-syntax","path":"src/lint/PreferredTypeSyntax.res","range":[1,9,1,15],"severity":"warning","message":"Prefer `dict<_>` over `Dict.t<_>`","symbol":"Dict.t"},{"rule":"preferred-type-syntax","path":"src/lint/PreferredTypeSyntax.res","range":[5,15,5,21],"severity":"warning","message":"Prefer `dict<_>` over `Dict.t<_>`","symbol":"Dict.t"},{"rule":"preferred-type-syntax","path":"src/lint/PreferredTypeSyntax.res","range":[2,9,2,22],"severity":"warning","message":"Prefer `dict<_>` over `Dict.t<_>`","symbol":"Stdlib.Dict.t"},{"rule":"preferred-type-syntax","path":"src/lint/PreferredTypeSyntax.res","range":[6,21,6,34],"severity":"warning","message":"Prefer `dict<_>` over `Dict.t<_>`","symbol":"Stdlib.Dict.t"},{"rule":"preferred-type-syntax","path":"src/lint/PreferredTypeSyntax.resi","range":[1,9,1,15],"severity":"warning","message":"Prefer `dict<_>` over `Dict.t<_>`","symbol":"Dict.t"},{"rule":"preferred-type-syntax","path":"src/lint/PreferredTypeSyntax.resi","range":[5,15,5,21],"severity":"warning","message":"Prefer `dict<_>` over `Dict.t<_>`","symbol":"Dict.t"},{"rule":"preferred-type-syntax","path":"src/lint/PreferredTypeSyntax.resi","range":[2,9,2,22],"severity":"warning","message":"Prefer `dict<_>` over `Dict.t<_>`","symbol":"Stdlib.Dict.t"},{"rule":"preferred-type-syntax","path":"src/lint/PreferredTypeSyntax.resi","range":[6,21,6,34],"severity":"warning","message":"Prefer `dict<_>` over `Dict.t<_>`","symbol":"Stdlib.Dict.t"},{"rule":"single-use-function","path":"src/lint/SingleUse.res","range":[1,6,1,12],"severity":"warning","message":"Inline this helper unless it is a meaningful reusable abstraction.","symbol":"helper"}] diff --git a/tests/tools_tests/src/generated/GeneratedConsumer.res b/tests/tools_tests/src/generated/GeneratedConsumer.res new file mode 100644 index 00000000000..9b610196d6a --- /dev/null +++ b/tests/tools_tests/src/generated/GeneratedConsumer.res @@ -0,0 +1,9 @@ +type wrapper = { + item: GeneratedModel.generatedType, +} + +let next = GeneratedModel.generatedValue + 1 + +let acceptGenerated = (value: GeneratedModel.generatedType) => { + next + GeneratedModel.generatedValue + value.id +} diff --git a/tests/tools_tests/src/generated/GeneratedModel.res b/tests/tools_tests/src/generated/GeneratedModel.res new file mode 100644 index 00000000000..979e1069543 --- /dev/null +++ b/tests/tools_tests/src/generated/GeneratedModel.res @@ -0,0 +1,5 @@ +type generatedType = { + id: int, +} + +let generatedValue = 41 diff --git a/tests/tools_tests/src/lint/ForbiddenGeneratedReference.res b/tests/tools_tests/src/lint/ForbiddenGeneratedReference.res new file mode 100644 index 00000000000..9b610196d6a --- /dev/null +++ b/tests/tools_tests/src/lint/ForbiddenGeneratedReference.res @@ -0,0 +1,9 @@ +type wrapper = { + item: GeneratedModel.generatedType, +} + +let next = GeneratedModel.generatedValue + 1 + +let acceptGenerated = (value: GeneratedModel.generatedType) => { + next + GeneratedModel.generatedValue + value.id +} diff --git a/tests/tools_tests/src/lint/ForbiddenGeneratedReference.resi b/tests/tools_tests/src/lint/ForbiddenGeneratedReference.resi new file mode 100644 index 00000000000..0482ee9f2e4 --- /dev/null +++ b/tests/tools_tests/src/lint/ForbiddenGeneratedReference.resi @@ -0,0 +1,5 @@ +type wrapper = { + item: GeneratedModel.generatedType, +} + +let acceptGenerated: GeneratedModel.generatedType => int diff --git a/tests/tools_tests/test.sh b/tests/tools_tests/test.sh index 9bd40b9478f..822c3474a87 100755 --- a/tests/tools_tests/test.sh +++ b/tests/tools_tests/test.sh @@ -48,6 +48,19 @@ for file in src/lint/*.{res,resi}; do fi done +generated_file="src/generated/GeneratedConsumer.res" +generated_output="src/expected/$(basename $generated_file).lint.expected" +../../_build/install/default/bin/rescript-tools lint "$generated_file" > "$generated_output" || true +if [ "$RUNNER_OS" == "Windows" ]; then + perl -pi -e 's/\r\n/\n/g' -- "$generated_output" +fi + +generated_json_output="src/expected/$(basename $generated_file).lint.json.expected" +../../_build/install/default/bin/rescript-tools lint "$generated_file" --json > "$generated_json_output" || true +if [ "$RUNNER_OS" == "Windows" ]; then + perl -pi -e 's/\r\n/\n/g' -- "$generated_json_output" +fi + unbuilt_file="unbuilt/ForbiddenExplicitNoCmt.res" unbuilt_output="src/expected/$(basename $unbuilt_file).lint.expected" ../../_build/install/default/bin/rescript-tools lint "$unbuilt_file" > "$unbuilt_output" || true diff --git a/tools/src/lint_analysis.ml b/tools/src/lint_analysis.ml index 794af0d0385..6d596d7a6a4 100644 --- a/tools/src/lint_analysis.ml +++ b/tools/src/lint_analysis.ml @@ -13,6 +13,13 @@ let starts_with_path ~prefix path = type forbidden_symbol = {kind: forbidden_reference_kind; path: string list} +type resolved_symbol = { + kind: forbidden_reference_kind; + path: string list; + declaration_source_path: string option; + source_root_reference_kind: forbidden_source_root_reference_kind option; +} + type forbidden_item_match = | ForbiddenItemExact | ForbiddenItemModulePrefix of int @@ -64,6 +71,28 @@ let declared_symbol_path ~module_name (declared : _ SharedTypes.Declared.t) = module_name :: SharedTypes.ModulePath.toPath declared.modulePath declared.name.txt +let rec source_path_of_module_path = function + | SharedTypes.ModulePath.File (uri, _) -> Some (Uri.toPath uri) + | SharedTypes.ModulePath.IncludedModule (_, module_path) -> + source_path_of_module_path module_path + | SharedTypes.ModulePath.ExportedModule {modulePath; _} -> + source_path_of_module_path modulePath + | SharedTypes.ModulePath.NotVisible -> None + +let declared_source_path (declared : _ SharedTypes.Declared.t) = + source_path_of_module_path declared.modulePath + +let path_is_within_root ~path ~root = + let path = if Files.exists path then Unix.realpath path else path in + let root = if Files.exists root then Unix.realpath root else root in + if root = "" then false + else + let root_length = String.length root in + Files.pathStartsWith path root + && (String.length path = root_length + || root.[root_length - 1] = Filename.dir_sep.[0] + || path.[root_length] = Filename.dir_sep.[0]) + let rec take_path count path = if count <= 0 then [] else @@ -424,10 +453,19 @@ module Ast = struct end module Typed = struct - let kind_of_tip = function - | Tip.Module -> ForbiddenReferenceModule - | Tip.Value -> ForbiddenReferenceValue - | Tip.Type | Tip.Field _ | Tip.Constructor _ -> ForbiddenReferenceType + let forbidden_source_root_reference_kind_matches left right = left = right + + let resolved_symbol_of_declared ~module_name ~kind declared = + { + kind; + path = declared_symbol_path ~module_name declared; + declaration_source_path = declared_source_path declared; + source_root_reference_kind = + (match kind with + | ForbiddenReferenceValue -> Some ForbiddenSourceRootReferenceValue + | ForbiddenReferenceType -> Some ForbiddenSourceRootReferenceType + | ForbiddenReferenceModule -> None); + } let resolve_global_symbol ~package ~module_name ~path ~tip = match ProcessCmt.fileForModule module_name ~package with @@ -439,76 +477,102 @@ module Typed = struct match tip with | Tip.Value -> Stamps.findValue env.file.stamps stamp - |> Option.map (fun declared -> - declared_symbol_path ~module_name:env.file.moduleName - declared) + |> Option.map + (resolved_symbol_of_declared ~module_name:env.file.moduleName + ~kind:ForbiddenReferenceValue) | Tip.Type -> Stamps.findType env.file.stamps stamp - |> Option.map (fun declared -> - declared_symbol_path ~module_name:env.file.moduleName - declared) + |> Option.map + (resolved_symbol_of_declared ~module_name:env.file.moduleName + ~kind:ForbiddenReferenceType) | Tip.Module -> Stamps.findModule env.file.stamps stamp - |> Option.map (fun declared -> - declared_symbol_path ~module_name:env.file.moduleName - declared) + |> Option.map + (resolved_symbol_of_declared ~module_name:env.file.moduleName + ~kind:ForbiddenReferenceModule) | Tip.Field field_name -> Stamps.findType env.file.stamps stamp |> Option.map (fun declared -> - declared_symbol_path ~module_name:env.file.moduleName - declared - @ [field_name]) + { + kind = ForbiddenReferenceType; + path = + declared_symbol_path ~module_name:env.file.moduleName + declared + @ [field_name]; + declaration_source_path = declared_source_path declared; + source_root_reference_kind = None; + }) | Tip.Constructor constructor_name -> Stamps.findType env.file.stamps stamp |> Option.map (fun declared -> - declared_symbol_path ~module_name:env.file.moduleName - declared - @ [constructor_name])) + { + kind = ForbiddenReferenceType; + path = + declared_symbol_path ~module_name:env.file.moduleName + declared + @ [constructor_name]; + declaration_source_path = declared_source_path declared; + source_root_reference_kind = None; + })) let resolve_local_symbol ~(file : File.t) ~tip stamp = match tip with | Tip.Value -> Stamps.findValue file.stamps stamp - |> Option.map (fun declared -> - declared_symbol_path ~module_name:file.moduleName declared) + |> Option.map + (resolved_symbol_of_declared ~module_name:file.moduleName + ~kind:ForbiddenReferenceValue) | Tip.Type -> Stamps.findType file.stamps stamp - |> Option.map (fun declared -> - declared_symbol_path ~module_name:file.moduleName declared) + |> Option.map + (resolved_symbol_of_declared ~module_name:file.moduleName + ~kind:ForbiddenReferenceType) | Tip.Module -> Stamps.findModule file.stamps stamp - |> Option.map (fun declared -> - declared_symbol_path ~module_name:file.moduleName declared) + |> Option.map + (resolved_symbol_of_declared ~module_name:file.moduleName + ~kind:ForbiddenReferenceModule) | Tip.Field field_name -> Stamps.findType file.stamps stamp |> Option.map (fun declared -> - declared_symbol_path ~module_name:file.moduleName declared - @ [field_name]) + { + kind = ForbiddenReferenceType; + path = + declared_symbol_path ~module_name:file.moduleName declared + @ [field_name]; + declaration_source_path = declared_source_path declared; + source_root_reference_kind = None; + }) | Tip.Constructor constructor_name -> Stamps.findType file.stamps stamp |> Option.map (fun declared -> - declared_symbol_path ~module_name:file.moduleName declared - @ [constructor_name]) + { + kind = ForbiddenReferenceType; + path = + declared_symbol_path ~module_name:file.moduleName declared + @ [constructor_name]; + declaration_source_path = declared_source_path declared; + source_root_reference_kind = None; + }) let symbol (full : SharedTypes.full) (loc_item : locItem) = match loc_item.locType with | Typed (_, _typ, LocalReference (stamp, tip)) -> resolve_local_symbol ~file:full.file ~tip stamp - |> Option.map (fun path -> {kind = kind_of_tip tip; path}) | Typed (_, _typ, GlobalReference (module_name, path, tip)) -> resolve_global_symbol ~package:full.package ~module_name ~path ~tip - |> Option.map (fun path -> {kind = kind_of_tip tip; path}) | Typed (_, _, (Definition _ | NotFound)) | LModule _ | TopLevelModule _ | Constant _ | TypeDefinition _ -> None let forbidden_reference_findings ~config ~path (full : SharedTypes.full) = let matching_rule symbol = + let forbidden_symbol = {kind = symbol.kind; path = symbol.path} in config.forbidden_reference |> List.find_map (fun (rule : forbidden_reference_rule) -> if (not rule.enabled) || rule.items = [] then None else - best_matching_forbidden_item rule.items symbol + best_matching_forbidden_item rule.items forbidden_symbol |> Option.map (fun item -> (rule, item))) in full.extra.locItems @@ -527,6 +591,44 @@ module Typed = struct (effective_forbidden_reference_item_message rule item) ?symbol ()))) + let forbidden_source_root_reference_findings ~config ~path + (full : SharedTypes.full) = + let rule = config.forbidden_source_root_reference in + if (not rule.enabled) || rule.roots = [] || rule.kinds = [] then [] + else + let file_is_within_root root = + path_is_within_root ~path ~root:root.abs_path + in + full.extra.locItems + |> List.filter_map (fun loc_item -> + match symbol full loc_item with + | None -> None + | Some symbol -> ( + match symbol.declaration_source_path with + | None -> None + | Some declaration_source_path -> + rule.roots + |> List.find_opt (fun root -> + (not (file_is_within_root root)) + && (match symbol.source_root_reference_kind with + | None -> false + | Some symbol_kind -> + rule.kinds + |> List.exists (fun kind -> + forbidden_source_root_reference_kind_matches + kind symbol_kind)) + && path_is_within_root ~path:declaration_source_path + ~root:root.abs_path) + |> Option.map (fun root -> + let symbol = Some (String.concat "." symbol.path) in + raw_finding ~rule:"forbidden-source-root-reference" + ~abs_path:path ~loc:loc_item.loc + ~severity:rule.severity + ~message: + (effective_forbidden_source_root_reference_message + rule root) + ?symbol ()))) + let is_function_type typ = match (Shared.dig typ).desc with | Tarrow _ -> true @@ -623,7 +725,7 @@ let dedupe_findings findings = in loop [] findings -let compare_raw_findings left right = +let compare_raw_findings (left : raw_finding) (right : raw_finding) = let span ({Location.loc_start; loc_end} : Location.t) = ( loc_end.pos_lnum - loc_start.pos_lnum, loc_end.pos_cnum - loc_start.pos_cnum ) @@ -664,6 +766,7 @@ let analyze_file ~config path = | Some full -> findings := Typed.forbidden_reference_findings ~config ~path full + @ Typed.forbidden_source_root_reference_findings ~config ~path full @ Typed.single_use_function_findings ~config ~path ~local_function_bindings:ast.local_function_bindings full @ !findings); diff --git a/tools/src/lint_config.ml b/tools/src/lint_config.ml index 9146c8ba4ee..a4ae6fbd51f 100644 --- a/tools/src/lint_config.ml +++ b/tools/src/lint_config.ml @@ -10,6 +10,17 @@ let default_single_use_function_rule : single_use_function_rule = let default_alias_avoidance_rule : alias_avoidance_rule = {enabled = true; severity = SeverityWarning; message = None} +let default_forbidden_source_root_reference_rule : + forbidden_source_root_reference_rule = + { + enabled = true; + severity = SeverityError; + message = None; + roots = []; + kinds = + [ForbiddenSourceRootReferenceValue; ForbiddenSourceRootReferenceType]; + } + let default_preferred_type_syntax_rule : preferred_type_syntax_rule = {enabled = true; severity = SeverityWarning; message = None; dict = false} @@ -22,6 +33,8 @@ let default_config = forbidden_reference = [default_forbidden_reference_rule]; single_use_function = default_single_use_function_rule; alias_avoidance = default_alias_avoidance_rule; + forbidden_source_root_reference = + default_forbidden_source_root_reference_rule; preferred_type_syntax = default_preferred_type_syntax_rule; rewrite = { @@ -46,6 +59,11 @@ let parse_forbidden_reference_kind = function | "type" -> Some ForbiddenReferenceType | _ -> None +let parse_forbidden_source_root_reference_kind = function + | "value" -> Some ForbiddenSourceRootReferenceValue + | "type" -> Some ForbiddenSourceRootReferenceType + | _ -> None + let result_all results = let rec loop acc = function | [] -> Ok (List.rev acc) @@ -104,7 +122,7 @@ let parse_item_objects ~rule = function (Printf.sprintf "error: lint rule `%s` field `items` must be an array of objects" rule) -let parse_config_json json = +let parse_config_json ?config_dir json = let lint_json = json |> Json.get "lint" in let rules = match Option.bind lint_json (Json.get "rules") with @@ -213,6 +231,78 @@ let parse_config_json json = } : alias_avoidance_rule) in + let parse_forbidden_source_root_reference_rule rule : + (forbidden_source_root_reference_rule, string) result = + let roots = + match Option.bind (rule |> Json.get "roots") Json.array with + | None -> Ok [] + | Some roots_json -> + roots_json + |> List.mapi (fun index root_json -> + match Json.string root_json with + | None -> + Error + (Printf.sprintf + "error: lint rule `forbidden-source-root-reference` root \ + %d must be a string" + (index + 1)) + | Some root -> + let display_path = Lint_support.Path.normalize_rel_path root in + let root = + if Filename.is_relative root then + match config_dir with + | Some config_dir -> Filename.concat config_dir root + | None -> root + else root + in + let abs_path = + if Files.exists root then Unix.realpath root else root + in + Ok {display_path; abs_path}) + |> result_all + in + let kinds = + match Option.bind (rule |> Json.get "kinds") Json.array with + | None -> Ok default_forbidden_source_root_reference_rule.kinds + | Some kinds_json -> + kinds_json + |> List.mapi (fun index kind_json -> + match Json.string kind_json with + | None -> + Error + (Printf.sprintf + "error: lint rule `forbidden-source-root-reference` kind \ + %d must be a string" + (index + 1)) + | Some kind -> ( + match parse_forbidden_source_root_reference_kind kind with + | Some kind -> Ok kind + | None -> + Error + (Printf.sprintf + "error: lint rule `forbidden-source-root-reference` \ + kind `%s` must be one of `value` or `type`" + kind))) + |> result_all + in + Result.bind roots (fun roots -> + Result.map + (fun kinds -> + ({ + enabled = + Lint_support.Json.bool_with_default ~default:true rule + "enabled"; + severity = + parse_rule_severity + ~default: + default_forbidden_source_root_reference_rule.severity rule; + message = parse_rule_message rule; + roots; + kinds; + } + : forbidden_source_root_reference_rule)) + kinds) + in let parse_preferred_type_syntax_rule rule : (preferred_type_syntax_rule, string) result = Ok @@ -283,24 +373,32 @@ let parse_config_json json = (get_rule "alias-avoidance")) (fun alias_avoidance -> Result.bind - (parse_singleton_rule ~rule:"preferred-type-syntax" - ~default:default_preferred_type_syntax_rule - parse_preferred_type_syntax_rule - (get_rule "preferred-type-syntax")) - (fun preferred_type_syntax -> - Ok - { - forbidden_reference; - single_use_function; - alias_avoidance; - preferred_type_syntax; - rewrite = + (parse_singleton_rule ~rule:"forbidden-source-root-reference" + ~default:default_forbidden_source_root_reference_rule + parse_forbidden_source_root_reference_rule + (get_rule "forbidden-source-root-reference")) + (fun forbidden_source_root_reference -> + Result.bind + (parse_singleton_rule ~rule:"preferred-type-syntax" + ~default:default_preferred_type_syntax_rule + parse_preferred_type_syntax_rule + (get_rule "preferred-type-syntax")) + (fun preferred_type_syntax -> + Ok { - prefer_switch; - no_optional_some; - preferred_type_syntax = preferred_type_syntax_rewrite; - }; - })))) + forbidden_reference; + single_use_function; + alias_avoidance; + forbidden_source_root_reference; + preferred_type_syntax; + rewrite = + { + prefer_switch; + no_optional_some; + preferred_type_syntax = + preferred_type_syntax_rewrite; + }; + }))))) let discover_config_path start_path = let rec loop path = @@ -333,4 +431,6 @@ let load ?config_path target_path = match config_path with | None -> Ok default_config | Some path -> - Result.bind (Lint_support.Json.read_file path) parse_config_json + Result.bind + (Lint_support.Json.read_file path) + (parse_config_json ~config_dir:(Filename.dirname path)) diff --git a/tools/src/lint_shared.ml b/tools/src/lint_shared.ml index a35d330c3bd..a10805c92bd 100644 --- a/tools/src/lint_shared.ml +++ b/tools/src/lint_shared.ml @@ -50,6 +50,20 @@ type alias_avoidance_rule = { message: string option; } +type forbidden_source_root_reference_kind = + | ForbiddenSourceRootReferenceValue + | ForbiddenSourceRootReferenceType + +type forbidden_source_root = {display_path: string; abs_path: string} + +type forbidden_source_root_reference_rule = { + enabled: bool; + severity: severity; + message: string option; + roots: forbidden_source_root list; + kinds: forbidden_source_root_reference_kind list; +} + type preferred_type_syntax_rule = { enabled: bool; severity: severity; @@ -77,6 +91,7 @@ type config = { forbidden_reference: forbidden_reference_rule list; single_use_function: single_use_function_rule; alias_avoidance: alias_avoidance_rule; + forbidden_source_root_reference: forbidden_source_root_reference_rule; preferred_type_syntax: preferred_type_syntax_rule; rewrite: rewrite_config; } @@ -118,6 +133,10 @@ let forbidden_reference_kind_to_string = function | ForbiddenReferenceValue -> "value" | ForbiddenReferenceType -> "type" +let forbidden_source_root_reference_kind_to_string = function + | ForbiddenSourceRootReferenceValue -> "value" + | ForbiddenSourceRootReferenceType -> "type" + let forbidden_reference_default_message = "Forbidden reference" let single_use_function_default_message = "Local function is only used once" @@ -125,6 +144,10 @@ let single_use_function_default_message = "Local function is only used once" let alias_avoidance_default_message = "Use the fully qualified reference directly instead of creating a local alias" +let forbidden_source_root_reference_default_message root = + Printf.sprintf "Do not reference declarations from `%s` directly" + root.display_path + let preferred_type_syntax_default_message = "Prefer `dict<_>` over `Dict.t<_>`" let preferred_type_syntax_dict_path path = @@ -173,6 +196,18 @@ let alias_avoidance_rule_info = rewrite_note = None; } +let forbidden_source_root_reference_rule_info = + { + namespace = "lint"; + rule = "forbidden-source-root-reference"; + summary = + "Report references whose declarations come from configured source roots."; + details = + "Uses typed declaration origin paths so generated or otherwise \ + restricted source trees can be blocked by folder root."; + rewrite_note = None; + } + let preferred_type_syntax_rule_info = { namespace = "lint"; @@ -223,6 +258,7 @@ let configurable_rule_infos = forbidden_reference_rule_info; single_use_function_rule_info; alias_avoidance_rule_info; + forbidden_source_root_reference_rule_info; preferred_type_syntax_rule_info; prefer_switch_rule_info; no_optional_some_rule_info; @@ -272,6 +308,18 @@ let effective_single_use_function_message (rule : single_use_function_rule) = let effective_alias_avoidance_message (rule : alias_avoidance_rule) = Option.value rule.message ~default:alias_avoidance_default_message +let effective_forbidden_source_root_reference_message + (rule : forbidden_source_root_reference_rule) root = + Option.value rule.message + ~default:(forbidden_source_root_reference_default_message root) + +let forbidden_source_root_reference_message_setting + (rule : forbidden_source_root_reference_rule) = + match (rule.message, rule.roots) with + | Some message, _ -> message + | None, [root] -> forbidden_source_root_reference_default_message root + | None, _ -> "Do not reference declarations from these source roots directly" + let effective_preferred_type_syntax_message (rule : preferred_type_syntax_rule) = Option.value rule.message ~default:preferred_type_syntax_default_message @@ -361,6 +409,34 @@ let rule_listings_of_config (config : config) = }; ] in + let forbidden_source_root_reference_listings = + let rule = config.forbidden_source_root_reference in + [ + { + namespace = forbidden_source_root_reference_rule_info.namespace; + rule = forbidden_source_root_reference_rule_info.rule; + instance = None; + active = rule.enabled && rule.roots <> [] && rule.kinds <> []; + summary = forbidden_source_root_reference_rule_info.summary; + details = forbidden_source_root_reference_rule_info.details; + settings = + [ + ("enabled", RuleSettingBool rule.enabled); + ("severity", RuleSettingString (severity_to_string rule.severity)); + ( "message", + RuleSettingString + (forbidden_source_root_reference_message_setting rule) ); + ( "roots", + RuleSettingStringList + (rule.roots |> List.map (fun root -> root.display_path)) ); + ( "kinds", + RuleSettingStringList + (rule.kinds + |> List.map forbidden_source_root_reference_kind_to_string) ); + ]; + }; + ] + in let preferred_type_syntax_listings = let rule = config.preferred_type_syntax in [ @@ -384,7 +460,8 @@ let rule_listings_of_config (config : config) = ] in forbidden_reference_listings @ single_use_function_listings - @ alias_avoidance_listings @ preferred_type_syntax_listings + @ alias_avoidance_listings @ forbidden_source_root_reference_listings + @ preferred_type_syntax_listings @ [ { namespace = prefer_switch_rule_info.namespace; From 34cd85ce663261ded4e9e6dacd6614c92b6e796a Mon Sep 17 00:00:00 2001 From: Gabriel Nordeborn Date: Sun, 19 Apr 2026 20:33:48 +0200 Subject: [PATCH 13/17] Add JSON schema for AI tools config Signed-off-by: Gabriel Nordeborn --- docs/docson/rescript-lint-schema.json | 285 ++++++++++++++++++++++++++ docs/rescript_ai.md | 12 +- package.json | 1 + tests/tools_tests/.rescript-lint.json | 1 + tests/tools_tests/test.sh | 2 + 5 files changed, 300 insertions(+), 1 deletion(-) create mode 100644 docs/docson/rescript-lint-schema.json diff --git a/docs/docson/rescript-lint-schema.json b/docs/docson/rescript-lint-schema.json new file mode 100644 index 00000000000..7fcd8ea160a --- /dev/null +++ b/docs/docson/rescript-lint-schema.json @@ -0,0 +1,285 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "ReScript AI Tools Config", + "description": "Configuration for `rescript-tools` AI-oriented lint and rewrite commands. This schema covers both the recommended namespaced shape (`lint.rules` and `rewrite.rules`) and the older top-level `rules` shortcut for lint-only configs.", + "type": "object", + "properties": { + "$schema": { + "type": "string", + "description": "Optional schema URI or path for editor support." + }, + "lint": { + "$ref": "#/definitions/lint-config" + }, + "rewrite": { + "$ref": "#/definitions/rewrite-config" + }, + "rules": { + "$ref": "#/definitions/lint-rules", + "description": "Deprecated lint-only shortcut. Prefer `lint.rules` for new configs." + } + }, + "additionalProperties": false, + "definitions": { + "severity": { + "type": "string", + "enum": ["error", "warning"], + "description": "Finding severity. Defaults depend on the rule." + }, + "enabled": { + "type": "boolean", + "description": "Whether the rule is enabled. Default: true." + }, + "message": { + "type": "string", + "description": "Optional custom message. When omitted, the built-in default message for that rule is used." + }, + "forbidden-reference-kind": { + "type": "string", + "enum": ["module", "value", "type"], + "description": "Which kind of symbol path is being forbidden." + }, + "forbidden-reference-item": { + "type": "object", + "description": "A single forbidden symbol target. Item-level `message` overrides the enclosing rule message when this item matches.", + "properties": { + "kind": { + "$ref": "#/definitions/forbidden-reference-kind" + }, + "path": { + "type": "string", + "description": "Dot-separated symbol path such as `Belt.Array.map` or `Js.Json.t`." + }, + "message": { + "$ref": "#/definitions/message" + } + }, + "required": ["kind", "path"], + "additionalProperties": false + }, + "forbidden-reference-rule": { + "type": "object", + "description": "Report references to configured modules, values, or types. Module items also match nested references beneath that module path.", + "properties": { + "enabled": { + "$ref": "#/definitions/enabled" + }, + "severity": { + "$ref": "#/definitions/severity" + }, + "message": { + "$ref": "#/definitions/message" + }, + "items": { + "type": "array", + "description": "Targets to forbid. Multiple items can share one severity and default message.", + "items": { + "$ref": "#/definitions/forbidden-reference-item" + } + } + }, + "additionalProperties": false + }, + "forbidden-reference-config": { + "description": "Either one forbidden-reference rule object or an array of rule objects. Arrays let you group distinct severities or default messages.", + "oneOf": [ + { + "$ref": "#/definitions/forbidden-reference-rule" + }, + { + "type": "array", + "items": { + "$ref": "#/definitions/forbidden-reference-rule" + } + } + ] + }, + "single-use-function-rule": { + "type": "object", + "description": "Report same-file local functions that are defined once and used once. Default severity: warning.", + "properties": { + "enabled": { + "$ref": "#/definitions/enabled" + }, + "severity": { + "$ref": "#/definitions/severity" + }, + "message": { + "$ref": "#/definitions/message" + } + }, + "additionalProperties": false + }, + "alias-avoidance-rule": { + "type": "object", + "description": "Report pass-through aliases such as `let alias = Module.value`, `type alias = Module.t`, or `module Alias = Long.Module.Path`. Default severity: warning.", + "properties": { + "enabled": { + "$ref": "#/definitions/enabled" + }, + "severity": { + "$ref": "#/definitions/severity" + }, + "message": { + "$ref": "#/definitions/message" + } + }, + "additionalProperties": false + }, + "forbidden-source-root-reference-kind": { + "type": "string", + "enum": ["value", "type"], + "description": "Kinds that can be checked by declaration source root. Modules are not supported by this rule yet." + }, + "forbidden-source-root-reference-rule": { + "type": "object", + "description": "Report references whose declarations come from configured source roots. Root paths are resolved relative to the config file. The rule does not report when the current file is also inside the matching forbidden root.", + "properties": { + "enabled": { + "$ref": "#/definitions/enabled" + }, + "severity": { + "$ref": "#/definitions/severity" + }, + "message": { + "$ref": "#/definitions/message" + }, + "roots": { + "type": "array", + "description": "Source folder roots to block, such as `src/generated` or `src/__generated__`.", + "items": { + "type": "string" + } + }, + "kinds": { + "type": "array", + "description": "Declaration kinds to block from those roots. Default: `[\"value\", \"type\"]`.", + "items": { + "$ref": "#/definitions/forbidden-source-root-reference-kind" + } + } + }, + "additionalProperties": false + }, + "preferred-type-syntax-lint-rule": { + "type": "object", + "description": "Prefer canonical builtin type syntax where available. Today this supports the `dict<_>` preference over `Dict.t<_>` and `Stdlib.Dict.t<_>`. Default severity: warning.", + "properties": { + "enabled": { + "$ref": "#/definitions/enabled" + }, + "severity": { + "$ref": "#/definitions/severity" + }, + "message": { + "$ref": "#/definitions/message" + }, + "dict": { + "type": "boolean", + "description": "When true, report `Dict.t<_>` and `Stdlib.Dict.t<_>` in favor of `dict<_>`. Default: false." + } + }, + "additionalProperties": false + }, + "lint-rules": { + "type": "object", + "description": "Configured lint rules. Omitted rules keep their built-in defaults.", + "properties": { + "forbidden-reference": { + "$ref": "#/definitions/forbidden-reference-config" + }, + "single-use-function": { + "$ref": "#/definitions/single-use-function-rule" + }, + "alias-avoidance": { + "$ref": "#/definitions/alias-avoidance-rule" + }, + "forbidden-source-root-reference": { + "$ref": "#/definitions/forbidden-source-root-reference-rule" + }, + "preferred-type-syntax": { + "$ref": "#/definitions/preferred-type-syntax-lint-rule" + } + }, + "additionalProperties": false + }, + "lint-config": { + "type": "object", + "description": "Namespace for lint rules.", + "properties": { + "rules": { + "$ref": "#/definitions/lint-rules" + } + }, + "additionalProperties": false + }, + "prefer-switch-rewrite-rule": { + "type": "object", + "description": "Rewrite `if` and ternary control flow into canonical `switch` forms.", + "properties": { + "enabled": { + "$ref": "#/definitions/enabled" + }, + "if": { + "type": "boolean", + "description": "When true, rewrite eligible `if` / `else if` branches. Default: true." + }, + "ternary": { + "type": "boolean", + "description": "When true, rewrite eligible ternary expressions. Default: true." + } + }, + "additionalProperties": false + }, + "no-optional-some-rewrite-rule": { + "type": "object", + "description": "Rewrite redundant optional-argument wrapping from `~label=?Some(expr)` to the direct labeled form.", + "properties": { + "enabled": { + "$ref": "#/definitions/enabled" + } + }, + "additionalProperties": false + }, + "preferred-type-syntax-rewrite-rule": { + "type": "object", + "description": "Rewrite supported type spellings into canonical builtin syntax. Today this supports rewriting `Dict.t<_>` and `Stdlib.Dict.t<_>` to `dict<_>`.", + "properties": { + "enabled": { + "$ref": "#/definitions/enabled" + }, + "dict": { + "type": "boolean", + "description": "When true, rewrite `Dict.t<_>` and `Stdlib.Dict.t<_>` to `dict<_>`. Default: false." + } + }, + "additionalProperties": false + }, + "rewrite-rules": { + "type": "object", + "description": "Configured rewrite rules. Omitted rules keep their built-in defaults.", + "properties": { + "prefer-switch": { + "$ref": "#/definitions/prefer-switch-rewrite-rule" + }, + "no-optional-some": { + "$ref": "#/definitions/no-optional-some-rewrite-rule" + }, + "preferred-type-syntax": { + "$ref": "#/definitions/preferred-type-syntax-rewrite-rule" + } + }, + "additionalProperties": false + }, + "rewrite-config": { + "type": "object", + "description": "Namespace for rewrite rules.", + "properties": { + "rules": { + "$ref": "#/definitions/rewrite-rules" + } + }, + "additionalProperties": false + } + } +} diff --git a/docs/rescript_ai.md b/docs/rescript_ai.md index 479d8cd3519..973517b443d 100644 --- a/docs/rescript_ai.md +++ b/docs/rescript_ai.md @@ -27,7 +27,7 @@ Initial rules - Rewrite: `prefer-switch`, `no-optional-some`, `preferred-type-syntax` Support -- Add `.rescript-lint.json` config support, AI tooling docs, and golden tests for the new command surface +- Add `.rescript-lint.json` config support, a shipped JSON schema, AI tooling docs, and golden tests for the new command surface ``` ## Goal @@ -180,10 +180,17 @@ Possible file names: - `.rescript-lint.json` - `rescript-lint.json` +Schema: + +- shipped at `docs/docson/rescript-lint-schema.json` +- can be referenced from config files through a `$schema` field for editor completion and validation +- the schema covers both the recommended namespaced shape and the deprecated top-level `rules` lint shortcut + Example shape: ```json { + "$schema": "./node_modules/rescript/docs/docson/rescript-lint-schema.json", "lint": { "rules": { "forbidden-reference": [ @@ -222,6 +229,8 @@ Example shape: `forbidden-reference` accepts either one rule object or an array of rule objects. Each `items` entry must be an object with `kind` (`module`, `value`, or `type`) and `path`; `message` is optional at both the rule and item level. +The older top-level `rules` field is still accepted for lint-only configs, but +new configs should prefer `lint.rules`. ## Lint V1 Rules @@ -295,6 +304,7 @@ Example: ```json { + "$schema": "./node_modules/rescript/docs/docson/rescript-lint-schema.json", "lint": { "rules": { "forbidden-source-root-reference": { diff --git a/package.json b/package.json index 115298467d4..79a6a4891f3 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "COPYING.LESSER", "CREDITS.md", "docs/docson/build-schema.json", + "docs/docson/rescript-lint-schema.json", "cli" ], "exports": { diff --git a/tests/tools_tests/.rescript-lint.json b/tests/tools_tests/.rescript-lint.json index a3aca39cbb8..e2e81e52748 100644 --- a/tests/tools_tests/.rescript-lint.json +++ b/tests/tools_tests/.rescript-lint.json @@ -1,4 +1,5 @@ { + "$schema": "../../docs/docson/rescript-lint-schema.json", "lint": { "rules": { "forbidden-reference": [ diff --git a/tests/tools_tests/test.sh b/tests/tools_tests/test.sh index 822c3474a87..b60fa5a4f6f 100755 --- a/tests/tools_tests/test.sh +++ b/tests/tools_tests/test.sh @@ -33,6 +33,8 @@ for file in src/docstrings-format/*.{res,resi,md}; do fi done +node -e "JSON.parse(require('node:fs').readFileSync('../../docs/docson/rescript-lint-schema.json', 'utf8'))" || exit 1 + # Test lint command for file in src/lint/*.{res,resi}; do output="src/expected/$(basename $file).lint.expected" From 1d0d8dace9bf0d7c6c996c1aaa16c4c2c944f5f9 Mon Sep 17 00:00:00 2001 From: Gabriel Nordeborn Date: Sun, 19 Apr 2026 20:38:09 +0200 Subject: [PATCH 14/17] update docs --- docs/rescript_ai.md | 41 ++++++++++++++++++++++++++++++----------- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/docs/rescript_ai.md b/docs/rescript_ai.md index 973517b443d..01978e043c9 100644 --- a/docs/rescript_ai.md +++ b/docs/rescript_ai.md @@ -23,8 +23,16 @@ Commands - `find-references`: Finds references from either a symbol path or a source location. Initial rules -- Lint: `forbidden-reference`, `single-use-function`, `alias-avoidance`, `forbidden-source-root-reference`, `preferred-type-syntax` -- Rewrite: `prefer-switch`, `no-optional-some`, `preferred-type-syntax` +- Lint: + - `forbidden-reference`: Bans using configured module, value, and type references. + - `single-use-function`: Reports local helper functions that are defined once and only used once. + - `alias-avoidance`: Reports local aliases like `let f = Module.f`, `type t = Module.t`, and `module M = Long.Path`. Prefer the fully qualified reference instead. + - `forbidden-source-root-reference`: Reports value and type references whose declarations come from configured source roots such as generated code folders. + - `preferred-type-syntax`: Reports non-canonical type spellings like `Dict.t<_>` in favor of builtin syntax like `dict<_>`. +- Rewrite: + - `prefer-switch`: Rewrites eligible `if` / `else if` chains and ternaries into canonical `switch` forms. + - `no-optional-some`: Rewrites redundant `~label=?Some(expr)` optional-argument wrapping into the direct labeled form. + - `preferred-type-syntax`: Rewrites supported non-canonical type spellings like `Dict.t<_>` into builtin syntax like `dict<_>`. Support - Add `.rescript-lint.json` config support, a shipped JSON schema, AI tooling docs, and golden tests for the new command surface @@ -132,7 +140,14 @@ Proposed fields: Example JSON finding: ```json -{"rule":"forbidden-reference","path":"src/A.res","range":[12,2,12,20],"severity":"error","symbol":"Belt.Array.forEach","message":"Forbidden reference"} +{ + "rule": "forbidden-reference", + "path": "src/A.res", + "range": [12, 2, 12, 20], + "severity": "error", + "symbol": "Belt.Array.forEach", + "message": "Forbidden reference" +} ``` ## Lint Output Contract @@ -198,8 +213,8 @@ Example shape: "severity": "error", "message": "Do not use Belt.Array helpers here.", "items": [ - {"kind": "module", "path": "Belt.Array"}, - {"kind": "value", "path": "Belt.Array.forEach"} + { "kind": "module", "path": "Belt.Array" }, + { "kind": "value", "path": "Belt.Array.forEach" } ] }, { @@ -329,6 +344,10 @@ This section is intentionally a scratchpad for future rules. - disallow plain functions that return JSX; require them to be defined as React components instead - goal: keep file shape predictable and preserve HMR-friendly module boundaries +### FFI shape + +- disallow `@obj external` + ### Size limits - file length limits @@ -408,9 +427,9 @@ Lint and rewrite config should each live under their own namespace in `.rescript "forbidden-reference": { "severity": "error", "items": [ - {"kind": "value", "path": "Belt.Array.forEach"}, - {"kind": "value", "path": "Belt.Array.map"}, - {"kind": "type", "path": "Js.Json.t"} + { "kind": "value", "path": "Belt.Array.forEach" }, + { "kind": "value", "path": "Belt.Array.map" }, + { "kind": "type", "path": "Js.Json.t" } ] }, "single-use-function": { @@ -424,9 +443,9 @@ Lint and rewrite config should each live under their own namespace in `.rescript }, "rewrite": { "rules": { - "prefer-switch": {"enabled": true, "if": true, "ternary": true}, - "no-optional-some": {"enabled": true}, - "preferred-type-syntax": {"enabled": true, "dict": true} + "prefer-switch": { "enabled": true, "if": true, "ternary": true }, + "no-optional-some": { "enabled": true }, + "preferred-type-syntax": { "enabled": true, "dict": true } } } } From a46fa7737b7215573845ddeac3624c420ac21371 Mon Sep 17 00:00:00 2001 From: Gabriel Nordeborn Date: Mon, 20 Apr 2026 12:45:59 +0200 Subject: [PATCH 15/17] rename binary --- Makefile | 2 +- cli/common/bins.js | 1 + cli/rescript-assist.js | 11 + docs/rescript_ai.md | 40 ++- docs/skills/rescript-ai-template/SKILL.md | 16 +- package.json | 1 + packages/@rescript/darwin-arm64/bin.d.ts | 1 + packages/@rescript/darwin-arm64/bin.js | 1 + packages/@rescript/darwin-arm64/package.json | 1 + packages/@rescript/darwin-x64/bin.d.ts | 1 + packages/@rescript/darwin-x64/bin.js | 1 + packages/@rescript/darwin-x64/package.json | 1 + packages/@rescript/linux-arm64/bin.d.ts | 1 + packages/@rescript/linux-arm64/bin.js | 1 + packages/@rescript/linux-arm64/package.json | 1 + packages/@rescript/linux-x64/bin.d.ts | 1 + packages/@rescript/linux-x64/bin.js | 1 + packages/@rescript/linux-x64/package.json | 1 + packages/@rescript/win32-x64/bin.d.ts | 1 + packages/@rescript/win32-x64/bin.js | 1 + packages/@rescript/win32-x64/package.json | 1 + packages/artifacts.json | 4 +- scripts/copyExes.js | 1 + .../RewriteIfs.res.rewrite.diff.expected | 32 +- .../RewriteIfs.res.rewrite.diff.json.expected | 2 +- ...riteOptionalSome.res.rewrite.diff.expected | 18 +- ...ptionalSome.res.rewrite.diff.json.expected | 2 +- .../src/expected/rewrite-root.diff.expected | 50 +-- .../expected/rewrite-root.diff.json.expected | 2 +- tests/tools_tests/test.sh | 54 ++-- tools/bin/ai_cli.ml | 296 ++++++++++++++++++ tools/bin/assist_main.ml | 1 + tools/bin/dune | 9 + tools/bin/main.ml | 257 +-------------- 34 files changed, 471 insertions(+), 343 deletions(-) create mode 100644 cli/rescript-assist.js create mode 100644 tools/bin/ai_cli.ml create mode 100644 tools/bin/assist_main.ml diff --git a/Makefile b/Makefile index 088d3d60003..1b541b8f806 100644 --- a/Makefile +++ b/Makefile @@ -100,7 +100,7 @@ clean-rewatch: COMPILER_SOURCE_DIRS := compiler tests analysis tools COMPILER_SOURCES = $(shell find $(COMPILER_SOURCE_DIRS) -type f \( -name '*.ml' -o -name '*.mli' -o -name '*.dune' -o -name dune -o -name dune-project \)) -COMPILER_BIN_NAMES := bsc rescript-editor-analysis rescript-tools +COMPILER_BIN_NAMES := bsc rescript-assist rescript-editor-analysis rescript-tools COMPILER_EXES := $(addsuffix .exe,$(addprefix $(BIN_DIR)/,$(COMPILER_BIN_NAMES))) COMPILER_DUNE_BINS := $(addsuffix $(PLATFORM_EXE_EXT),$(addprefix $(DUNE_BIN_DIR)/,$(COMPILER_BIN_NAMES))) diff --git a/cli/common/bins.js b/cli/common/bins.js index 5800a54f5ea..85260d3e257 100644 --- a/cli/common/bins.js +++ b/cli/common/bins.js @@ -40,6 +40,7 @@ export const { binDir, binPaths: { bsc_exe, + rescript_assist_exe, rescript_editor_analysis_exe, rescript_tools_exe, rescript_exe, diff --git a/cli/rescript-assist.js b/cli/rescript-assist.js new file mode 100644 index 00000000000..330fe4430af --- /dev/null +++ b/cli/rescript-assist.js @@ -0,0 +1,11 @@ +#!/usr/bin/env node + +// @ts-check + +import * as child_process from "node:child_process"; + +import { rescript_assist_exe } from "./common/bins.js"; + +const args = process.argv.slice(2); + +child_process.spawnSync(rescript_assist_exe, args, { stdio: "inherit" }); diff --git a/docs/rescript_ai.md b/docs/rescript_ai.md index 01978e043c9..4237966632b 100644 --- a/docs/rescript_ai.md +++ b/docs/rescript_ai.md @@ -2,7 +2,7 @@ This document captures a first working direction for AI-oriented tooling in ReScript. -The lint command is the first concrete piece, but the intended surface is broader: commands and output modes in `rescript-tools` that are shaped for LLMs and agent workflows. +The lint command is the first concrete piece, but the intended surface is broader: a standalone `rescript-assist` CLI with commands and output modes shaped for LLMs and agent workflows. A starter agent skill template for this workflow lives at `docs/skills/rescript-ai-template/SKILL.md`. @@ -13,7 +13,7 @@ Keep this section updated as the command surface and rule set change so the PR description stays current. ```text -Add AI-oriented `rescript-tools` commands +Add the standalone `rescript-assist` CLI for AI-oriented workflows Commands - `lint`: Runs configurable AI-oriented lint checks on a file or project root using source and typed information. @@ -35,7 +35,7 @@ Initial rules - `preferred-type-syntax`: Rewrites supported non-canonical type spellings like `Dict.t<_>` into builtin syntax like `dict<_>`. Support -- Add `.rescript-lint.json` config support, a shipped JSON schema, AI tooling docs, and golden tests for the new command surface +- Add `.rescript-lint.json` config support, a shipped JSON schema, AI tooling docs, golden tests, and the standalone `rescript-assist` binary ``` ## Goal @@ -61,12 +61,12 @@ belong in the same tool surface. Recommended first shape: ```sh -rescript-tools lint [--config ] [--json] -rescript-tools rewrite [--config ] [--diff] [--json] -rescript-tools active-rules [--config ] [--json] -rescript-tools show [--kind ] [--context ] [--comments ] -rescript-tools find-references [--kind ] [--context ] -rescript-tools find-references --file --line --col +rescript-assist lint [--config ] [--json] +rescript-assist rewrite [--config ] [--diff] [--json] +rescript-assist active-rules [--config ] [--json] +rescript-assist show [--kind ] [--context ] [--comments ] +rescript-assist find-references [--kind ] [--context ] +rescript-assist find-references --file --line --col ``` Notes: @@ -89,7 +89,7 @@ Notes: ## Recommended Placement -- CLI entrypoint: `tools/bin/main.ml` +- CLI entrypoints: `tools/bin/assist_main.ml` for `rescript-assist`, `tools/bin/main.ml` for `rescript-tools` - command implementation: new module in `tools/src/` - semantic loading and package resolution: reuse `analysis/src/Cmt.ml` - typedtree-derived structure/reference data: reuse `analysis/src/ProcessCmt.ml` @@ -348,6 +348,24 @@ This section is intentionally a scratchpad for future rules. - disallow `@obj external` +### Preset policy checks + +Some policy checks are better expressed as named presets than as a generic +pattern language. + +Examples: + +- `no-obj-magic` +- `no-raw` +- `no-obj-external` + +These should expand to concrete lint implementations internally, but the config +surface should stay narrow: + +- enable or disable the preset +- optionally override the message +- optionally scope it to certain folders or paths + ### Size limits - file length limits @@ -403,7 +421,7 @@ Aggressive source normalization for agents should be a separate command rather t Recommended first shape: ```sh -rescript-tools rewrite [--config ] [--diff] [--json] +rescript-assist rewrite [--config ] [--diff] [--json] ``` Goal: diff --git a/docs/skills/rescript-ai-template/SKILL.md b/docs/skills/rescript-ai-template/SKILL.md index ee87f5c83d5..d32cbfec729 100644 --- a/docs/skills/rescript-ai-template/SKILL.md +++ b/docs/skills/rescript-ai-template/SKILL.md @@ -1,6 +1,6 @@ --- name: rescript-ai-template -description: Template skill for agents working in a ReScript codebase with rescript-tools lint and related AI-oriented workflows. Use when creating a project-specific skill for file-by-file editing, lint-repair loops, and semantic checks. +description: Template skill for agents working in a ReScript codebase with rescript-assist lint and related AI-oriented workflows. Use when creating a project-specific skill for file-by-file editing, lint-repair loops, and semantic checks. --- # ReScript AI Template @@ -12,7 +12,7 @@ Use this as a starting point for a project-specific skill. Replace placeholders Use this skill when: - editing `.res` or `.resi` files -- fixing lint findings from `rescript-tools lint` +- fixing lint findings from `rescript-assist lint` - doing small file-first repair loops - checking semantic issues that depend on `.cmt/.cmti` @@ -20,7 +20,7 @@ Use this skill when: 1. Prefer a file-first loop: - edit one file - - run `rescript-tools lint ` + - run `rescript-assist lint ` - fix findings - rerun lint for that file @@ -36,9 +36,9 @@ Use this skill when: ## Commands ```sh -rescript-tools lint -rescript-tools lint -rescript-tools lint --json +rescript-assist lint +rescript-assist lint +rescript-assist lint --json ``` Optional project verification commands: @@ -86,7 +86,7 @@ Fill this section in for the actual repo: For one-file work: ```sh -rescript-tools lint src/File.res +rescript-assist lint src/File.res ``` Fix the reported findings, then rerun the same command until clean. @@ -94,7 +94,7 @@ Fix the reported findings, then rerun the same command until clean. For broader fallout: ```sh -rescript-tools lint src/ +rescript-assist lint src/ ``` ## Notes diff --git a/package.json b/package.json index 79a6a4891f3..52aa2a0a4db 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ }, "bin": { "bsc": "cli/bsc.js", + "rescript-assist": "cli/rescript-assist.js", "rescript": "cli/rescript.js", "rescript-tools": "cli/rescript-tools.js" }, diff --git a/packages/@rescript/darwin-arm64/bin.d.ts b/packages/@rescript/darwin-arm64/bin.d.ts index f6fa8daaca5..c200ffa66fd 100644 --- a/packages/@rescript/darwin-arm64/bin.d.ts +++ b/packages/@rescript/darwin-arm64/bin.d.ts @@ -4,6 +4,7 @@ export const binPaths: BinaryPaths; export type BinaryPaths = { bsc_exe: string; + rescript_assist_exe: string; rescript_tools_exe: string; rescript_editor_analysis_exe: string; rescript_exe: string; diff --git a/packages/@rescript/darwin-arm64/bin.js b/packages/@rescript/darwin-arm64/bin.js index aff7c9c9d93..9eaeeb36bfc 100644 --- a/packages/@rescript/darwin-arm64/bin.js +++ b/packages/@rescript/darwin-arm64/bin.js @@ -6,6 +6,7 @@ export const binDir = path.join(import.meta.dirname, "bin"); export const binPaths = { bsc_exe: path.join(binDir, "bsc.exe"), + rescript_assist_exe: path.join(binDir, "rescript-assist.exe"), rescript_tools_exe: path.join(binDir, "rescript-tools.exe"), rescript_editor_analysis_exe: path.join( binDir, diff --git a/packages/@rescript/darwin-arm64/package.json b/packages/@rescript/darwin-arm64/package.json index 680d8e5b997..5eecf0c75e0 100644 --- a/packages/@rescript/darwin-arm64/package.json +++ b/packages/@rescript/darwin-arm64/package.json @@ -34,6 +34,7 @@ "provenance": true, "executableFiles": [ "./bin/bsc.exe", + "./bin/rescript-assist.exe", "./bin/rescript-editor-analysis.exe", "./bin/rescript-tools.exe", "./bin/rescript.exe" diff --git a/packages/@rescript/darwin-x64/bin.d.ts b/packages/@rescript/darwin-x64/bin.d.ts index f6fa8daaca5..c200ffa66fd 100644 --- a/packages/@rescript/darwin-x64/bin.d.ts +++ b/packages/@rescript/darwin-x64/bin.d.ts @@ -4,6 +4,7 @@ export const binPaths: BinaryPaths; export type BinaryPaths = { bsc_exe: string; + rescript_assist_exe: string; rescript_tools_exe: string; rescript_editor_analysis_exe: string; rescript_exe: string; diff --git a/packages/@rescript/darwin-x64/bin.js b/packages/@rescript/darwin-x64/bin.js index aff7c9c9d93..9eaeeb36bfc 100644 --- a/packages/@rescript/darwin-x64/bin.js +++ b/packages/@rescript/darwin-x64/bin.js @@ -6,6 +6,7 @@ export const binDir = path.join(import.meta.dirname, "bin"); export const binPaths = { bsc_exe: path.join(binDir, "bsc.exe"), + rescript_assist_exe: path.join(binDir, "rescript-assist.exe"), rescript_tools_exe: path.join(binDir, "rescript-tools.exe"), rescript_editor_analysis_exe: path.join( binDir, diff --git a/packages/@rescript/darwin-x64/package.json b/packages/@rescript/darwin-x64/package.json index deab29a39a9..8a43d6e2660 100644 --- a/packages/@rescript/darwin-x64/package.json +++ b/packages/@rescript/darwin-x64/package.json @@ -34,6 +34,7 @@ "provenance": true, "executableFiles": [ "./bin/bsc.exe", + "./bin/rescript-assist.exe", "./bin/rescript-editor-analysis.exe", "./bin/rescript-tools.exe", "./bin/rescript.exe" diff --git a/packages/@rescript/linux-arm64/bin.d.ts b/packages/@rescript/linux-arm64/bin.d.ts index f6fa8daaca5..c200ffa66fd 100644 --- a/packages/@rescript/linux-arm64/bin.d.ts +++ b/packages/@rescript/linux-arm64/bin.d.ts @@ -4,6 +4,7 @@ export const binPaths: BinaryPaths; export type BinaryPaths = { bsc_exe: string; + rescript_assist_exe: string; rescript_tools_exe: string; rescript_editor_analysis_exe: string; rescript_exe: string; diff --git a/packages/@rescript/linux-arm64/bin.js b/packages/@rescript/linux-arm64/bin.js index aff7c9c9d93..9eaeeb36bfc 100644 --- a/packages/@rescript/linux-arm64/bin.js +++ b/packages/@rescript/linux-arm64/bin.js @@ -6,6 +6,7 @@ export const binDir = path.join(import.meta.dirname, "bin"); export const binPaths = { bsc_exe: path.join(binDir, "bsc.exe"), + rescript_assist_exe: path.join(binDir, "rescript-assist.exe"), rescript_tools_exe: path.join(binDir, "rescript-tools.exe"), rescript_editor_analysis_exe: path.join( binDir, diff --git a/packages/@rescript/linux-arm64/package.json b/packages/@rescript/linux-arm64/package.json index 5a24d5af968..4680294e044 100644 --- a/packages/@rescript/linux-arm64/package.json +++ b/packages/@rescript/linux-arm64/package.json @@ -34,6 +34,7 @@ "provenance": true, "executableFiles": [ "./bin/bsc.exe", + "./bin/rescript-assist.exe", "./bin/rescript-editor-analysis.exe", "./bin/rescript-tools.exe", "./bin/rescript.exe" diff --git a/packages/@rescript/linux-x64/bin.d.ts b/packages/@rescript/linux-x64/bin.d.ts index f6fa8daaca5..c200ffa66fd 100644 --- a/packages/@rescript/linux-x64/bin.d.ts +++ b/packages/@rescript/linux-x64/bin.d.ts @@ -4,6 +4,7 @@ export const binPaths: BinaryPaths; export type BinaryPaths = { bsc_exe: string; + rescript_assist_exe: string; rescript_tools_exe: string; rescript_editor_analysis_exe: string; rescript_exe: string; diff --git a/packages/@rescript/linux-x64/bin.js b/packages/@rescript/linux-x64/bin.js index aff7c9c9d93..9eaeeb36bfc 100644 --- a/packages/@rescript/linux-x64/bin.js +++ b/packages/@rescript/linux-x64/bin.js @@ -6,6 +6,7 @@ export const binDir = path.join(import.meta.dirname, "bin"); export const binPaths = { bsc_exe: path.join(binDir, "bsc.exe"), + rescript_assist_exe: path.join(binDir, "rescript-assist.exe"), rescript_tools_exe: path.join(binDir, "rescript-tools.exe"), rescript_editor_analysis_exe: path.join( binDir, diff --git a/packages/@rescript/linux-x64/package.json b/packages/@rescript/linux-x64/package.json index a5402514201..4e72332f393 100644 --- a/packages/@rescript/linux-x64/package.json +++ b/packages/@rescript/linux-x64/package.json @@ -34,6 +34,7 @@ "provenance": true, "executableFiles": [ "./bin/bsc.exe", + "./bin/rescript-assist.exe", "./bin/rescript-editor-analysis.exe", "./bin/rescript-tools.exe", "./bin/rescript.exe" diff --git a/packages/@rescript/win32-x64/bin.d.ts b/packages/@rescript/win32-x64/bin.d.ts index f6fa8daaca5..c200ffa66fd 100644 --- a/packages/@rescript/win32-x64/bin.d.ts +++ b/packages/@rescript/win32-x64/bin.d.ts @@ -4,6 +4,7 @@ export const binPaths: BinaryPaths; export type BinaryPaths = { bsc_exe: string; + rescript_assist_exe: string; rescript_tools_exe: string; rescript_editor_analysis_exe: string; rescript_exe: string; diff --git a/packages/@rescript/win32-x64/bin.js b/packages/@rescript/win32-x64/bin.js index aff7c9c9d93..9eaeeb36bfc 100644 --- a/packages/@rescript/win32-x64/bin.js +++ b/packages/@rescript/win32-x64/bin.js @@ -6,6 +6,7 @@ export const binDir = path.join(import.meta.dirname, "bin"); export const binPaths = { bsc_exe: path.join(binDir, "bsc.exe"), + rescript_assist_exe: path.join(binDir, "rescript-assist.exe"), rescript_tools_exe: path.join(binDir, "rescript-tools.exe"), rescript_editor_analysis_exe: path.join( binDir, diff --git a/packages/@rescript/win32-x64/package.json b/packages/@rescript/win32-x64/package.json index 4f98011b30b..a9284f64cb4 100644 --- a/packages/@rescript/win32-x64/package.json +++ b/packages/@rescript/win32-x64/package.json @@ -34,6 +34,7 @@ "provenance": true, "executableFiles": [ "./bin/bsc.exe", + "./bin/rescript-assist.exe", "./bin/rescript-editor-analysis.exe", "./bin/rescript-tools.exe", "./bin/rescript.exe" diff --git a/packages/artifacts.json b/packages/artifacts.json index a024c5ebc4e..46153bc886f 100644 --- a/packages/artifacts.json +++ b/packages/artifacts.json @@ -8,6 +8,7 @@ "LICENSE.MIT", "README.md", "cli/bsc.js", + "cli/rescript-assist.js", "cli/common/args.js", "cli/common/bins.js", "cli/common/minisocket.js", @@ -15,6 +16,7 @@ "cli/rescript-tools.js", "cli/rescript.js", "docs/docson/build-schema.json", + "docs/docson/rescript-lint-schema.json", "package.json" ], "@rescript/runtime": [ @@ -1240,4 +1242,4 @@ "lib/ocaml/Stdlib_WeakSet.res", "package.json" ] -} \ No newline at end of file +} diff --git a/scripts/copyExes.js b/scripts/copyExes.js index a7e2aadd92b..123c053de47 100755 --- a/scripts/copyExes.js +++ b/scripts/copyExes.js @@ -31,6 +31,7 @@ const shouldCopyRewatch = args.values.all || args.values.rewatch; if (shouldCopyCompiler) { copyExe(compilerBinDir, "rescript-editor-analysis"); + copyExe(compilerBinDir, "rescript-assist"); copyExe(compilerBinDir, "rescript-tools"); copyExe(compilerBinDir, "bsc"); } diff --git a/tests/tools_tests/src/expected/RewriteIfs.res.rewrite.diff.expected b/tests/tools_tests/src/expected/RewriteIfs.res.rewrite.diff.expected index e33ca6ef729..6d5cc59a14f 100644 --- a/tests/tools_tests/src/expected/RewriteIfs.res.rewrite.diff.expected +++ b/tests/tools_tests/src/expected/RewriteIfs.res.rewrite.diff.expected @@ -6,40 +6,42 @@ diff: ```diff --- a/src/rewrite/RewriteIfs.res +++ b/src/rewrite/RewriteIfs.res -@@ -1,16 +1,22 @@ +@@ -1,21 +1,22 @@ let flag = true let other = false --let direct = if flag { 1 } else { 2 } --let ternary = flag ? "yes" : "no" +-let direct = if flag { +- 1 +-} else { +- 2 +let direct = switch flag { +| true => 1 +| false => 2 -+} + } +-let ternary = flag ? "yes" : "no" +let ternary = switch flag { +| true => "yes" +| false => "no" +} --let chained = -- if flag { -- 1 -- } else if other { -- 2 -- } else { -- 3 -- } +-let chained = if flag { +- 1 +-} else if other { +- 2 +-} else { +- 3 +let chained = switch () { +| _ if flag => 1 +| _ if other => 2 +| _ => 3 -+} + } --let onlyWhen = if flag { Console.log("hit") } +-let onlyWhen = if flag { +- Console.log("hit") +let onlyWhen = switch flag { +| true => Console.log("hit") +| false => () -+} + } ``` summary: previewed 1 files, unchanged 0, rules: prefer-switch(4) diff --git a/tests/tools_tests/src/expected/RewriteIfs.res.rewrite.diff.json.expected b/tests/tools_tests/src/expected/RewriteIfs.res.rewrite.diff.json.expected index 4dafe4e6426..d92a37ec030 100644 --- a/tests/tools_tests/src/expected/RewriteIfs.res.rewrite.diff.json.expected +++ b/tests/tools_tests/src/expected/RewriteIfs.res.rewrite.diff.json.expected @@ -1 +1 @@ -[{"path":"src/rewrite/RewriteIfs.res","mode":"diff","changed":true,"applied":[{"rule":"prefer-switch","count":4,"note":"rewrote `if` / ternary branches into `switch`"}],"diff":"--- a/src/rewrite/RewriteIfs.res\n+++ b/src/rewrite/RewriteIfs.res\n@@ -1,16 +1,22 @@\n let flag = true\n let other = false\n\n-let direct = if flag { 1 } else { 2 }\n-let ternary = flag ? \"yes\" : \"no\"\n+let direct = switch flag {\n+| true => 1\n+| false => 2\n+}\n+let ternary = switch flag {\n+| true => \"yes\"\n+| false => \"no\"\n+}\n\n-let chained =\n- if flag {\n- 1\n- } else if other {\n- 2\n- } else {\n- 3\n- }\n+let chained = switch () {\n+| _ if flag => 1\n+| _ if other => 2\n+| _ => 3\n+}\n\n-let onlyWhen = if flag { Console.log(\"hit\") }\n+let onlyWhen = switch flag {\n+| true => Console.log(\"hit\")\n+| false => ()\n+}","rewritten":"let flag = true\nlet other = false\n\nlet direct = switch flag {\n| true => 1\n| false => 2\n}\nlet ternary = switch flag {\n| true => \"yes\"\n| false => \"no\"\n}\n\nlet chained = switch () {\n| _ if flag => 1\n| _ if other => 2\n| _ => 3\n}\n\nlet onlyWhen = switch flag {\n| true => Console.log(\"hit\")\n| false => ()\n}\n"}] +[{"path":"src/rewrite/RewriteIfs.res","mode":"diff","changed":true,"applied":[{"rule":"prefer-switch","count":4,"note":"rewrote `if` / ternary branches into `switch`"}],"diff":"--- a/src/rewrite/RewriteIfs.res\n+++ b/src/rewrite/RewriteIfs.res\n@@ -1,21 +1,22 @@\n let flag = true\n let other = false\n\n-let direct = if flag {\n- 1\n-} else {\n- 2\n+let direct = switch flag {\n+| true => 1\n+| false => 2\n }\n-let ternary = flag ? \"yes\" : \"no\"\n+let ternary = switch flag {\n+| true => \"yes\"\n+| false => \"no\"\n+}\n\n-let chained = if flag {\n- 1\n-} else if other {\n- 2\n-} else {\n- 3\n+let chained = switch () {\n+| _ if flag => 1\n+| _ if other => 2\n+| _ => 3\n }\n\n-let onlyWhen = if flag {\n- Console.log(\"hit\")\n+let onlyWhen = switch flag {\n+| true => Console.log(\"hit\")\n+| false => ()\n }","rewritten":"let flag = true\nlet other = false\n\nlet direct = switch flag {\n| true => 1\n| false => 2\n}\nlet ternary = switch flag {\n| true => \"yes\"\n| false => \"no\"\n}\n\nlet chained = switch () {\n| _ if flag => 1\n| _ if other => 2\n| _ => 3\n}\n\nlet onlyWhen = switch flag {\n| true => Console.log(\"hit\")\n| false => ()\n}\n"}] diff --git a/tests/tools_tests/src/expected/RewriteOptionalSome.res.rewrite.diff.expected b/tests/tools_tests/src/expected/RewriteOptionalSome.res.rewrite.diff.expected index 738aaad0e9e..fdf29b1fcae 100644 --- a/tests/tools_tests/src/expected/RewriteOptionalSome.res.rewrite.diff.expected +++ b/tests/tools_tests/src/expected/RewriteOptionalSome.res.rewrite.diff.expected @@ -7,21 +7,27 @@ diff: ```diff --- a/src/rewrite/RewriteOptionalSome.res +++ b/src/rewrite/RewriteOptionalSome.res -@@ -1,6 +1,12 @@ +@@ -1,15 +1,12 @@ let flag = true let useValue = (~value=?, ()) => value -let direct = useValue(~value=?Some(1), ()) --let nested = useValue(~value=?Some(if flag { 1 } else { 2 }), ()) --let fromComputation = useValue(~value=?Some(String.length("abc")), ()) +let direct = useValue(~value=1, ()) -+let nested = useValue( + let nested = useValue( +- ~value=?Some( +- if flag { +- 1 +- } else { +- 2 +- }, +- ), + ~value=switch flag { + | true => 1 + | false => 2 + }, -+ (), -+) + (), + ) +-let fromComputation = useValue(~value=?Some(String.length("abc")), ()) +let fromComputation = useValue(~value=String.length("abc"), ()) ``` diff --git a/tests/tools_tests/src/expected/RewriteOptionalSome.res.rewrite.diff.json.expected b/tests/tools_tests/src/expected/RewriteOptionalSome.res.rewrite.diff.json.expected index fbd5781f320..ddf4cd6333a 100644 --- a/tests/tools_tests/src/expected/RewriteOptionalSome.res.rewrite.diff.json.expected +++ b/tests/tools_tests/src/expected/RewriteOptionalSome.res.rewrite.diff.json.expected @@ -1 +1 @@ -[{"path":"src/rewrite/RewriteOptionalSome.res","mode":"diff","changed":true,"applied":[{"rule":"prefer-switch","count":1,"note":"rewrote `if` / ternary branches into `switch`"}, {"rule":"no-optional-some","count":3,"note":"rewrote `~label=?Some(expr)` into `~label=expr`"}],"diff":"--- a/src/rewrite/RewriteOptionalSome.res\n+++ b/src/rewrite/RewriteOptionalSome.res\n@@ -1,6 +1,12 @@\n let flag = true\n let useValue = (~value=?, ()) => value\n\n-let direct = useValue(~value=?Some(1), ())\n-let nested = useValue(~value=?Some(if flag { 1 } else { 2 }), ())\n-let fromComputation = useValue(~value=?Some(String.length(\"abc\")), ())\n+let direct = useValue(~value=1, ())\n+let nested = useValue(\n+ ~value=switch flag {\n+ | true => 1\n+ | false => 2\n+ },\n+ (),\n+)\n+let fromComputation = useValue(~value=String.length(\"abc\"), ())","rewritten":"let flag = true\nlet useValue = (~value=?, ()) => value\n\nlet direct = useValue(~value=1, ())\nlet nested = useValue(\n ~value=switch flag {\n | true => 1\n | false => 2\n },\n (),\n)\nlet fromComputation = useValue(~value=String.length(\"abc\"), ())\n"}] +[{"path":"src/rewrite/RewriteOptionalSome.res","mode":"diff","changed":true,"applied":[{"rule":"prefer-switch","count":1,"note":"rewrote `if` / ternary branches into `switch`"}, {"rule":"no-optional-some","count":3,"note":"rewrote `~label=?Some(expr)` into `~label=expr`"}],"diff":"--- a/src/rewrite/RewriteOptionalSome.res\n+++ b/src/rewrite/RewriteOptionalSome.res\n@@ -1,15 +1,12 @@\n let flag = true\n let useValue = (~value=?, ()) => value\n\n-let direct = useValue(~value=?Some(1), ())\n+let direct = useValue(~value=1, ())\n let nested = useValue(\n- ~value=?Some(\n- if flag {\n- 1\n- } else {\n- 2\n- },\n- ),\n+ ~value=switch flag {\n+ | true => 1\n+ | false => 2\n+ },\n (),\n )\n-let fromComputation = useValue(~value=?Some(String.length(\"abc\")), ())\n+let fromComputation = useValue(~value=String.length(\"abc\"), ())","rewritten":"let flag = true\nlet useValue = (~value=?, ()) => value\n\nlet direct = useValue(~value=1, ())\nlet nested = useValue(\n ~value=switch flag {\n | true => 1\n | false => 2\n },\n (),\n)\nlet fromComputation = useValue(~value=String.length(\"abc\"), ())\n"}] diff --git a/tests/tools_tests/src/expected/rewrite-root.diff.expected b/tests/tools_tests/src/expected/rewrite-root.diff.expected index cd3444e2827..8ccd2a82994 100644 --- a/tests/tools_tests/src/expected/rewrite-root.diff.expected +++ b/tests/tools_tests/src/expected/rewrite-root.diff.expected @@ -9,40 +9,42 @@ diff: ```diff --- a/src/rewrite/RewriteIfs.res +++ b/src/rewrite/RewriteIfs.res -@@ -1,16 +1,22 @@ +@@ -1,21 +1,22 @@ let flag = true let other = false --let direct = if flag { 1 } else { 2 } --let ternary = flag ? "yes" : "no" +-let direct = if flag { +- 1 +-} else { +- 2 +let direct = switch flag { +| true => 1 +| false => 2 -+} + } +-let ternary = flag ? "yes" : "no" +let ternary = switch flag { +| true => "yes" +| false => "no" +} --let chained = -- if flag { -- 1 -- } else if other { -- 2 -- } else { -- 3 -- } +-let chained = if flag { +- 1 +-} else if other { +- 2 +-} else { +- 3 +let chained = switch () { +| _ if flag => 1 +| _ if other => 2 +| _ => 3 -+} + } --let onlyWhen = if flag { Console.log("hit") } +-let onlyWhen = if flag { +- Console.log("hit") +let onlyWhen = switch flag { +| true => Console.log("hit") +| false => () -+} + } ``` path: src/rewrite/RewriteOptionalSome.res @@ -54,21 +56,27 @@ diff: ```diff --- a/src/rewrite/RewriteOptionalSome.res +++ b/src/rewrite/RewriteOptionalSome.res -@@ -1,6 +1,12 @@ +@@ -1,15 +1,12 @@ let flag = true let useValue = (~value=?, ()) => value -let direct = useValue(~value=?Some(1), ()) --let nested = useValue(~value=?Some(if flag { 1 } else { 2 }), ()) --let fromComputation = useValue(~value=?Some(String.length("abc")), ()) +let direct = useValue(~value=1, ()) -+let nested = useValue( + let nested = useValue( +- ~value=?Some( +- if flag { +- 1 +- } else { +- 2 +- }, +- ), + ~value=switch flag { + | true => 1 + | false => 2 + }, -+ (), -+) + (), + ) +-let fromComputation = useValue(~value=?Some(String.length("abc")), ()) +let fromComputation = useValue(~value=String.length("abc"), ()) ``` diff --git a/tests/tools_tests/src/expected/rewrite-root.diff.json.expected b/tests/tools_tests/src/expected/rewrite-root.diff.json.expected index 9d253d8e647..0660bc1418f 100644 --- a/tests/tools_tests/src/expected/rewrite-root.diff.json.expected +++ b/tests/tools_tests/src/expected/rewrite-root.diff.json.expected @@ -1 +1 @@ -[{"path":"src/rewrite/RewriteClean.res","mode":"diff","changed":false,"applied":[]},{"path":"src/rewrite/RewriteIfs.res","mode":"diff","changed":true,"applied":[{"rule":"prefer-switch","count":4,"note":"rewrote `if` / ternary branches into `switch`"}],"diff":"--- a/src/rewrite/RewriteIfs.res\n+++ b/src/rewrite/RewriteIfs.res\n@@ -1,16 +1,22 @@\n let flag = true\n let other = false\n\n-let direct = if flag { 1 } else { 2 }\n-let ternary = flag ? \"yes\" : \"no\"\n+let direct = switch flag {\n+| true => 1\n+| false => 2\n+}\n+let ternary = switch flag {\n+| true => \"yes\"\n+| false => \"no\"\n+}\n\n-let chained =\n- if flag {\n- 1\n- } else if other {\n- 2\n- } else {\n- 3\n- }\n+let chained = switch () {\n+| _ if flag => 1\n+| _ if other => 2\n+| _ => 3\n+}\n\n-let onlyWhen = if flag { Console.log(\"hit\") }\n+let onlyWhen = switch flag {\n+| true => Console.log(\"hit\")\n+| false => ()\n+}","rewritten":"let flag = true\nlet other = false\n\nlet direct = switch flag {\n| true => 1\n| false => 2\n}\nlet ternary = switch flag {\n| true => \"yes\"\n| false => \"no\"\n}\n\nlet chained = switch () {\n| _ if flag => 1\n| _ if other => 2\n| _ => 3\n}\n\nlet onlyWhen = switch flag {\n| true => Console.log(\"hit\")\n| false => ()\n}\n"},{"path":"src/rewrite/RewriteOptionalSome.res","mode":"diff","changed":true,"applied":[{"rule":"prefer-switch","count":1,"note":"rewrote `if` / ternary branches into `switch`"}, {"rule":"no-optional-some","count":3,"note":"rewrote `~label=?Some(expr)` into `~label=expr`"}],"diff":"--- a/src/rewrite/RewriteOptionalSome.res\n+++ b/src/rewrite/RewriteOptionalSome.res\n@@ -1,6 +1,12 @@\n let flag = true\n let useValue = (~value=?, ()) => value\n\n-let direct = useValue(~value=?Some(1), ())\n-let nested = useValue(~value=?Some(if flag { 1 } else { 2 }), ())\n-let fromComputation = useValue(~value=?Some(String.length(\"abc\")), ())\n+let direct = useValue(~value=1, ())\n+let nested = useValue(\n+ ~value=switch flag {\n+ | true => 1\n+ | false => 2\n+ },\n+ (),\n+)\n+let fromComputation = useValue(~value=String.length(\"abc\"), ())","rewritten":"let flag = true\nlet useValue = (~value=?, ()) => value\n\nlet direct = useValue(~value=1, ())\nlet nested = useValue(\n ~value=switch flag {\n | true => 1\n | false => 2\n },\n (),\n)\nlet fromComputation = useValue(~value=String.length(\"abc\"), ())\n"},{"path":"src/rewrite/RewritePreferredTypeSyntax.res","mode":"diff","changed":true,"applied":[{"rule":"preferred-type-syntax","count":4,"note":"rewrote `Dict.t<_>` into `dict<_>`"}],"diff":"--- a/src/rewrite/RewritePreferredTypeSyntax.res\n+++ b/src/rewrite/RewritePreferredTypeSyntax.res\n@@ -1,7 +1,7 @@\n type wrapped = {\n- items: Dict.t,\n- names: Stdlib.Dict.t,\n+ items: dict,\n+ names: dict,\n }\n\n-let dictValue: Dict.t = dict{}\n-let stdlibDictValue: Stdlib.Dict.t = dict{}\n+let dictValue: dict = dict{}\n+let stdlibDictValue: dict = dict{}","rewritten":"type wrapped = {\n items: dict,\n names: dict,\n}\n\nlet dictValue: dict = dict{}\nlet stdlibDictValue: dict = dict{}\n"},{"path":"src/rewrite/RewritePreferredTypeSyntax.resi","mode":"diff","changed":true,"applied":[{"rule":"preferred-type-syntax","count":4,"note":"rewrote `Dict.t<_>` into `dict<_>`"}],"diff":"--- a/src/rewrite/RewritePreferredTypeSyntax.resi\n+++ b/src/rewrite/RewritePreferredTypeSyntax.resi\n@@ -1,7 +1,7 @@\n type wrapped = {\n- items: Dict.t,\n- names: Stdlib.Dict.t,\n+ items: dict,\n+ names: dict,\n }\n\n-let dictValue: Dict.t\n-let stdlibDictValue: Stdlib.Dict.t\n+let dictValue: dict\n+let stdlibDictValue: dict","rewritten":"type wrapped = {\n items: dict,\n names: dict,\n}\n\nlet dictValue: dict\nlet stdlibDictValue: dict\n"}] +[{"path":"src/rewrite/RewriteClean.res","mode":"diff","changed":false,"applied":[]},{"path":"src/rewrite/RewriteIfs.res","mode":"diff","changed":true,"applied":[{"rule":"prefer-switch","count":4,"note":"rewrote `if` / ternary branches into `switch`"}],"diff":"--- a/src/rewrite/RewriteIfs.res\n+++ b/src/rewrite/RewriteIfs.res\n@@ -1,21 +1,22 @@\n let flag = true\n let other = false\n\n-let direct = if flag {\n- 1\n-} else {\n- 2\n+let direct = switch flag {\n+| true => 1\n+| false => 2\n }\n-let ternary = flag ? \"yes\" : \"no\"\n+let ternary = switch flag {\n+| true => \"yes\"\n+| false => \"no\"\n+}\n\n-let chained = if flag {\n- 1\n-} else if other {\n- 2\n-} else {\n- 3\n+let chained = switch () {\n+| _ if flag => 1\n+| _ if other => 2\n+| _ => 3\n }\n\n-let onlyWhen = if flag {\n- Console.log(\"hit\")\n+let onlyWhen = switch flag {\n+| true => Console.log(\"hit\")\n+| false => ()\n }","rewritten":"let flag = true\nlet other = false\n\nlet direct = switch flag {\n| true => 1\n| false => 2\n}\nlet ternary = switch flag {\n| true => \"yes\"\n| false => \"no\"\n}\n\nlet chained = switch () {\n| _ if flag => 1\n| _ if other => 2\n| _ => 3\n}\n\nlet onlyWhen = switch flag {\n| true => Console.log(\"hit\")\n| false => ()\n}\n"},{"path":"src/rewrite/RewriteOptionalSome.res","mode":"diff","changed":true,"applied":[{"rule":"prefer-switch","count":1,"note":"rewrote `if` / ternary branches into `switch`"}, {"rule":"no-optional-some","count":3,"note":"rewrote `~label=?Some(expr)` into `~label=expr`"}],"diff":"--- a/src/rewrite/RewriteOptionalSome.res\n+++ b/src/rewrite/RewriteOptionalSome.res\n@@ -1,15 +1,12 @@\n let flag = true\n let useValue = (~value=?, ()) => value\n\n-let direct = useValue(~value=?Some(1), ())\n+let direct = useValue(~value=1, ())\n let nested = useValue(\n- ~value=?Some(\n- if flag {\n- 1\n- } else {\n- 2\n- },\n- ),\n+ ~value=switch flag {\n+ | true => 1\n+ | false => 2\n+ },\n (),\n )\n-let fromComputation = useValue(~value=?Some(String.length(\"abc\")), ())\n+let fromComputation = useValue(~value=String.length(\"abc\"), ())","rewritten":"let flag = true\nlet useValue = (~value=?, ()) => value\n\nlet direct = useValue(~value=1, ())\nlet nested = useValue(\n ~value=switch flag {\n | true => 1\n | false => 2\n },\n (),\n)\nlet fromComputation = useValue(~value=String.length(\"abc\"), ())\n"},{"path":"src/rewrite/RewritePreferredTypeSyntax.res","mode":"diff","changed":true,"applied":[{"rule":"preferred-type-syntax","count":4,"note":"rewrote `Dict.t<_>` into `dict<_>`"}],"diff":"--- a/src/rewrite/RewritePreferredTypeSyntax.res\n+++ b/src/rewrite/RewritePreferredTypeSyntax.res\n@@ -1,7 +1,7 @@\n type wrapped = {\n- items: Dict.t,\n- names: Stdlib.Dict.t,\n+ items: dict,\n+ names: dict,\n }\n\n-let dictValue: Dict.t = dict{}\n-let stdlibDictValue: Stdlib.Dict.t = dict{}\n+let dictValue: dict = dict{}\n+let stdlibDictValue: dict = dict{}","rewritten":"type wrapped = {\n items: dict,\n names: dict,\n}\n\nlet dictValue: dict = dict{}\nlet stdlibDictValue: dict = dict{}\n"},{"path":"src/rewrite/RewritePreferredTypeSyntax.resi","mode":"diff","changed":true,"applied":[{"rule":"preferred-type-syntax","count":4,"note":"rewrote `Dict.t<_>` into `dict<_>`"}],"diff":"--- a/src/rewrite/RewritePreferredTypeSyntax.resi\n+++ b/src/rewrite/RewritePreferredTypeSyntax.resi\n@@ -1,7 +1,7 @@\n type wrapped = {\n- items: Dict.t,\n- names: Stdlib.Dict.t,\n+ items: dict,\n+ names: dict,\n }\n\n-let dictValue: Dict.t\n-let stdlibDictValue: Stdlib.Dict.t\n+let dictValue: dict\n+let stdlibDictValue: dict","rewritten":"type wrapped = {\n items: dict,\n names: dict,\n}\n\nlet dictValue: dict\nlet stdlibDictValue: dict\n"}] diff --git a/tests/tools_tests/test.sh b/tests/tools_tests/test.sh index b60fa5a4f6f..2d7000591fb 100755 --- a/tests/tools_tests/test.sh +++ b/tests/tools_tests/test.sh @@ -38,13 +38,13 @@ node -e "JSON.parse(require('node:fs').readFileSync('../../docs/docson/rescript- # Test lint command for file in src/lint/*.{res,resi}; do output="src/expected/$(basename $file).lint.expected" - ../../_build/install/default/bin/rescript-tools lint "$file" > "$output" || true + ../../_build/install/default/bin/rescript-assist lint "$file" > "$output" || true if [ "$RUNNER_OS" == "Windows" ]; then perl -pi -e 's/\r\n/\n/g' -- "$output" fi json_output="src/expected/$(basename $file).lint.json.expected" - ../../_build/install/default/bin/rescript-tools lint "$file" --json > "$json_output" || true + ../../_build/install/default/bin/rescript-assist lint "$file" --json > "$json_output" || true if [ "$RUNNER_OS" == "Windows" ]; then perl -pi -e 's/\r\n/\n/g' -- "$json_output" fi @@ -52,91 +52,91 @@ done generated_file="src/generated/GeneratedConsumer.res" generated_output="src/expected/$(basename $generated_file).lint.expected" -../../_build/install/default/bin/rescript-tools lint "$generated_file" > "$generated_output" || true +../../_build/install/default/bin/rescript-assist lint "$generated_file" > "$generated_output" || true if [ "$RUNNER_OS" == "Windows" ]; then perl -pi -e 's/\r\n/\n/g' -- "$generated_output" fi generated_json_output="src/expected/$(basename $generated_file).lint.json.expected" -../../_build/install/default/bin/rescript-tools lint "$generated_file" --json > "$generated_json_output" || true +../../_build/install/default/bin/rescript-assist lint "$generated_file" --json > "$generated_json_output" || true if [ "$RUNNER_OS" == "Windows" ]; then perl -pi -e 's/\r\n/\n/g' -- "$generated_json_output" fi unbuilt_file="unbuilt/ForbiddenExplicitNoCmt.res" unbuilt_output="src/expected/$(basename $unbuilt_file).lint.expected" -../../_build/install/default/bin/rescript-tools lint "$unbuilt_file" > "$unbuilt_output" || true +../../_build/install/default/bin/rescript-assist lint "$unbuilt_file" > "$unbuilt_output" || true if [ "$RUNNER_OS" == "Windows" ]; then perl -pi -e 's/\r\n/\n/g' -- "$unbuilt_output" fi unbuilt_json_output="src/expected/$(basename $unbuilt_file).lint.json.expected" -../../_build/install/default/bin/rescript-tools lint "$unbuilt_file" --json > "$unbuilt_json_output" || true +../../_build/install/default/bin/rescript-assist lint "$unbuilt_file" --json > "$unbuilt_json_output" || true if [ "$RUNNER_OS" == "Windows" ]; then perl -pi -e 's/\r\n/\n/g' -- "$unbuilt_json_output" fi -../../_build/install/default/bin/rescript-tools lint src/lint > src/expected/lint-root.expected || true +../../_build/install/default/bin/rescript-assist lint src/lint > src/expected/lint-root.expected || true if [ "$RUNNER_OS" == "Windows" ]; then perl -pi -e 's/\r\n/\n/g' -- src/expected/lint-root.expected fi -../../_build/install/default/bin/rescript-tools lint src/lint --json > src/expected/lint-root.json.expected || true +../../_build/install/default/bin/rescript-assist lint src/lint --json > src/expected/lint-root.json.expected || true if [ "$RUNNER_OS" == "Windows" ]; then perl -pi -e 's/\r\n/\n/g' -- src/expected/lint-root.json.expected fi # Test active-rules command -../../_build/install/default/bin/rescript-tools active-rules src/lint > src/expected/active-rules.expected || exit 1 +../../_build/install/default/bin/rescript-assist active-rules src/lint > src/expected/active-rules.expected || exit 1 if [ "$RUNNER_OS" == "Windows" ]; then perl -pi -e 's/\r\n/\n/g' -- src/expected/active-rules.expected fi -../../_build/install/default/bin/rescript-tools active-rules src/lint --json > src/expected/active-rules.json.expected || exit 1 +../../_build/install/default/bin/rescript-assist active-rules src/lint --json > src/expected/active-rules.json.expected || exit 1 if [ "$RUNNER_OS" == "Windows" ]; then perl -pi -e 's/\r\n/\n/g' -- src/expected/active-rules.json.expected fi # Test show command -../../_build/install/default/bin/rescript-tools show ShowFixture --kind module > src/expected/show-ShowFixture.expected || exit 1 +../../_build/install/default/bin/rescript-assist show ShowFixture --kind module > src/expected/show-ShowFixture.expected || exit 1 if [ "$RUNNER_OS" == "Windows" ]; then perl -pi -e 's/\r\n/\n/g' -- src/expected/show-ShowFixture.expected fi -../../_build/install/default/bin/rescript-tools show ShowFixture.Nested.makeGreeting --kind value > src/expected/show-ShowFixture.Nested.makeGreeting.expected || exit 1 +../../_build/install/default/bin/rescript-assist show ShowFixture.Nested.makeGreeting --kind value > src/expected/show-ShowFixture.Nested.makeGreeting.expected || exit 1 if [ "$RUNNER_OS" == "Windows" ]; then perl -pi -e 's/\r\n/\n/g' -- src/expected/show-ShowFixture.Nested.makeGreeting.expected fi -../../_build/install/default/bin/rescript-tools show ShowFixture.Nested.makeGreeting --kind value --comments omit > src/expected/show-ShowFixture.Nested.makeGreeting.no-comments.expected || exit 1 +../../_build/install/default/bin/rescript-assist show ShowFixture.Nested.makeGreeting --kind value --comments omit > src/expected/show-ShowFixture.Nested.makeGreeting.no-comments.expected || exit 1 if [ "$RUNNER_OS" == "Windows" ]; then perl -pi -e 's/\r\n/\n/g' -- src/expected/show-ShowFixture.Nested.makeGreeting.no-comments.expected fi -../../_build/install/default/bin/rescript-tools show ShowFixture.item --kind type > src/expected/show-ShowFixture.item.expected || exit 1 +../../_build/install/default/bin/rescript-assist show ShowFixture.item --kind type > src/expected/show-ShowFixture.item.expected || exit 1 if [ "$RUNNER_OS" == "Windows" ]; then perl -pi -e 's/\r\n/\n/g' -- src/expected/show-ShowFixture.item.expected fi -../../_build/install/default/bin/rescript-tools show String.localeCompare > src/expected/show-String.localeCompare.expected || exit 1 +../../_build/install/default/bin/rescript-assist show String.localeCompare > src/expected/show-String.localeCompare.expected || exit 1 if [ "$RUNNER_OS" == "Windows" ]; then perl -pi -e 's/\r\n/\n/g' -- src/expected/show-String.localeCompare.expected fi -../../_build/install/default/bin/rescript-tools show String --kind module > /dev/null || exit 1 +../../_build/install/default/bin/rescript-assist show String --kind module > /dev/null || exit 1 # Test find-references command -../../_build/install/default/bin/rescript-tools find-references FindReferencesFixture.makeGreeting --kind value > src/expected/find-references-FindReferencesFixture.makeGreeting.expected || exit 1 +../../_build/install/default/bin/rescript-assist find-references FindReferencesFixture.makeGreeting --kind value > src/expected/find-references-FindReferencesFixture.makeGreeting.expected || exit 1 if [ "$RUNNER_OS" == "Windows" ]; then perl -pi -e 's/\r\n/\n/g' -- src/expected/find-references-FindReferencesFixture.makeGreeting.expected fi -../../_build/install/default/bin/rescript-tools find-references --file src/find_references/FindReferencesUse.res --line 4 --col 35 > src/expected/find-references-location.expected || exit 1 +../../_build/install/default/bin/rescript-assist find-references --file src/find_references/FindReferencesUse.res --line 4 --col 35 > src/expected/find-references-location.expected || exit 1 if [ "$RUNNER_OS" == "Windows" ]; then perl -pi -e 's/\r\n/\n/g' -- src/expected/find-references-location.expected fi -../../_build/install/default/bin/rescript-tools find-references FindReferencesFixture --kind module > /dev/null || exit 1 +../../_build/install/default/bin/rescript-assist find-references FindReferencesFixture --kind module > /dev/null || exit 1 # Test rewrite command rm -rf .tmp-rewrite-tests @@ -147,7 +147,7 @@ for file in src/rewrite/*.{res,resi}; do cp "$file" "$tmp_file" output="src/expected/$(basename $file).rewrite.expected" - ../../_build/install/default/bin/rescript-tools rewrite "$tmp_file" > "$output" + ../../_build/install/default/bin/rescript-assist rewrite "$tmp_file" > "$output" if [ "$RUNNER_OS" == "Windows" ]; then perl -pi -e 's/\r\n/\n/g' -- "$output" fi @@ -160,19 +160,19 @@ for file in src/rewrite/*.{res,resi}; do cp "$file" "$tmp_file" json_output="src/expected/$(basename $file).rewrite.json.expected" - ../../_build/install/default/bin/rescript-tools rewrite "$tmp_file" --json > "$json_output" + ../../_build/install/default/bin/rescript-assist rewrite "$tmp_file" --json > "$json_output" if [ "$RUNNER_OS" == "Windows" ]; then perl -pi -e 's/\r\n/\n/g' -- "$json_output" fi diff_output="src/expected/$(basename $file).rewrite.diff.expected" - ../../_build/install/default/bin/rescript-tools rewrite "$file" --diff > "$diff_output" + ../../_build/install/default/bin/rescript-assist rewrite "$file" --diff > "$diff_output" if [ "$RUNNER_OS" == "Windows" ]; then perl -pi -e 's/\r\n/\n/g' -- "$diff_output" fi diff_json_output="src/expected/$(basename $file).rewrite.diff.json.expected" - ../../_build/install/default/bin/rescript-tools rewrite "$file" --diff --json > "$diff_json_output" + ../../_build/install/default/bin/rescript-assist rewrite "$file" --diff --json > "$diff_json_output" if [ "$RUNNER_OS" == "Windows" ]; then perl -pi -e 's/\r\n/\n/g' -- "$diff_json_output" fi @@ -181,7 +181,7 @@ done rm -rf .tmp-rewrite-tests/root mkdir -p .tmp-rewrite-tests/root cp src/rewrite/*.{res,resi} .tmp-rewrite-tests/root/ -../../_build/install/default/bin/rescript-tools rewrite .tmp-rewrite-tests/root > src/expected/rewrite-root.expected +../../_build/install/default/bin/rescript-assist rewrite .tmp-rewrite-tests/root > src/expected/rewrite-root.expected if [ "$RUNNER_OS" == "Windows" ]; then perl -pi -e 's/\r\n/\n/g' -- src/expected/rewrite-root.expected fi @@ -189,17 +189,17 @@ fi rm -rf .tmp-rewrite-tests/root mkdir -p .tmp-rewrite-tests/root cp src/rewrite/*.{res,resi} .tmp-rewrite-tests/root/ -../../_build/install/default/bin/rescript-tools rewrite .tmp-rewrite-tests/root --json > src/expected/rewrite-root.json.expected +../../_build/install/default/bin/rescript-assist rewrite .tmp-rewrite-tests/root --json > src/expected/rewrite-root.json.expected if [ "$RUNNER_OS" == "Windows" ]; then perl -pi -e 's/\r\n/\n/g' -- src/expected/rewrite-root.json.expected fi -../../_build/install/default/bin/rescript-tools rewrite src/rewrite --diff > src/expected/rewrite-root.diff.expected +../../_build/install/default/bin/rescript-assist rewrite src/rewrite --diff > src/expected/rewrite-root.diff.expected if [ "$RUNNER_OS" == "Windows" ]; then perl -pi -e 's/\r\n/\n/g' -- src/expected/rewrite-root.diff.expected fi -../../_build/install/default/bin/rescript-tools rewrite src/rewrite --diff --json > src/expected/rewrite-root.diff.json.expected +../../_build/install/default/bin/rescript-assist rewrite src/rewrite --diff --json > src/expected/rewrite-root.diff.json.expected if [ "$RUNNER_OS" == "Windows" ]; then perl -pi -e 's/\r\n/\n/g' -- src/expected/rewrite-root.diff.json.expected fi diff --git a/tools/bin/ai_cli.ml b/tools/bin/ai_cli.ml new file mode 100644 index 00000000000..e1cdb7a8336 --- /dev/null +++ b/tools/bin/ai_cli.ml @@ -0,0 +1,296 @@ +let log_and_exit = function + | Ok log -> + Printf.printf "%s\n" log; + exit 0 + | Error log -> + Printf.eprintf "%s\n" log; + exit 1 + +let banner = "ReScript Assist" + +let lint_help prog_name = + Printf.sprintf + {|%s + +Run AI-oriented lint checks on a file or directory + +Usage: %s lint [--config ] [--json] + +Notes: +- Text is the default output format +- Use --json for compact machine-readable JSON +- Both source AST and typed information are used when available + +Example: %s lint ./src/MyModule.res|} + banner prog_name prog_name + +let rewrite_help prog_name = + Printf.sprintf + {|%s + +Rewrite a file or directory into a narrower agent-oriented source form + +Usage: %s rewrite [--config ] [--diff] [--json] + +Notes: +- Files are rewritten in place +- Use --diff to preview the rewritten diff without modifying files +- Use --json for compact machine-readable output +- Rewrite rules are loaded from the same config discovery path as lint + +Example: %s rewrite ./src/MyModule.res|} + banner prog_name prog_name + +let active_rules_help prog_name = + Printf.sprintf + {|%s + +List lint and rewrite rules, whether they are currently active, and what they do + +Usage: %s active-rules [--config ] [--json] + +Notes: +- Uses the same config discovery path as lint and rewrite +- Includes both lint and rewrite rules +- Text is the default output format + +Example: %s active-rules ./src|} + banner prog_name prog_name + +let show_help prog_name = + Printf.sprintf + {|%s + +Show hover-style semantic information for a module, value, or type path + +Usage: %s show [--kind ] [--context ] [--comments ] + +Notes: +- Symbol paths are user-facing paths like String or String.localeCompare +- Context defaults to the current working directory +- Kind defaults to auto +- Comments default to include + +Example: %s show String.localeCompare|} + banner prog_name prog_name + +let find_references_help prog_name = + Printf.sprintf + {|%s + +Find references for a symbol path or for the symbol at a source location + +Usage: + %s find-references [--kind ] [--context ] + %s find-references --file --line --col + +Notes: +- Positional input means symbol-path mode +- Location mode uses 1-based line and column numbers +- Context defaults to the current working directory in symbol-path mode + +Example: %s find-references String.localeCompare|} + banner prog_name prog_name prog_name + +let help prog_name = + Printf.sprintf + {|%s + +Usage: %s [command] + +Commands: + +lint Run AI-oriented lint checks + [--config ] Use the given lint config file + [--json] Output compact JSON +rewrite Rewrite source into agent-oriented normal form + [--config ] Use the given lint config file + [--diff] Preview the rewritten diff without writing files + [--json] Output compact JSON +active-rules List lint/rewrite rules and whether they are active + [--config ] Use the given lint config file + [--json] Output compact JSON +show Show hover-style semantic information + [--kind ] Select what kind of symbol to resolve + [--context ] Resolve within the package for this path + [--comments ] Include or omit doc/comments in output +find-references Find references by symbol path + [--kind ] Select what kind of symbol to resolve + [--context ] Resolve within the package for this path +find-references --file Find references at a source location + [--line ] Use 1-based line number + [--col ] Use 1-based column number +-v, --version Print version +-h, --help Print help|} + banner prog_name + +let command_names = + ["lint"; "rewrite"; "active-rules"; "show"; "find-references"] + +let is_ai_command command = List.mem command command_names + +let moved_command_message ~prog_name command = + Printf.sprintf + "error: `%s` has moved to the standalone `%s` binary.\n\nUse:\n %s %s" + command prog_name prog_name command + +let main ~prog_name ~version () = + match Sys.argv |> Array.to_list |> List.tl with + | "lint" :: rest -> ( + match rest with + | ["-h"] | ["--help"] -> log_and_exit (Ok (lint_help prog_name)) + | path :: args -> ( + let rec parse_args config_path json = function + | [] -> Ok (config_path, json) + | "--json" :: rest -> parse_args config_path true rest + | "--config" :: config :: rest -> parse_args (Some config) json rest + | _ -> Error (lint_help prog_name) + in + match parse_args None false args with + | Error help -> log_and_exit (Error help) + | Ok (config_path, json) -> ( + match Tools.Lint.run ?config_path ~json path with + | Error err -> + prerr_endline err; + exit 2 + | Ok {Tools.Lint.output; has_findings} -> + if output <> "" then print_endline output; + exit (if has_findings then 1 else 0))) + | _ -> log_and_exit (Error (lint_help prog_name))) + | "rewrite" :: rest -> ( + match rest with + | ["-h"] | ["--help"] -> log_and_exit (Ok (rewrite_help prog_name)) + | path :: args -> ( + let rec parse_args config_path json diff = function + | [] -> Ok (config_path, json, diff) + | "--json" :: rest -> parse_args config_path true diff rest + | "--diff" :: rest -> parse_args config_path json true rest + | "--config" :: config :: rest -> + parse_args (Some config) json diff rest + | _ -> Error (rewrite_help prog_name) + in + match parse_args None false false args with + | Error help -> log_and_exit (Error help) + | Ok (config_path, json, diff) -> ( + let mode = if diff then Tools.Rewrite.Diff else Tools.Rewrite.Write in + match Tools.Rewrite.run ?config_path ~json ~mode path with + | Error err -> + prerr_endline err; + exit 2 + | Ok {Tools.Rewrite.output; _} -> + if output <> "" then print_endline output; + exit 0)) + | _ -> log_and_exit (Error (rewrite_help prog_name))) + | "active-rules" :: rest -> ( + match rest with + | ["-h"] | ["--help"] -> log_and_exit (Ok (active_rules_help prog_name)) + | path :: args -> ( + let rec parse_args config_path json = function + | [] -> Ok (config_path, json) + | "--json" :: rest -> parse_args config_path true rest + | "--config" :: config :: rest -> parse_args (Some config) json rest + | _ -> Error (active_rules_help prog_name) + in + match parse_args None false args with + | Error help -> log_and_exit (Error help) + | Ok (config_path, json) -> ( + match Tools.ActiveRules.run ?config_path ~json path with + | Error err -> + prerr_endline err; + exit 2 + | Ok {Tools.ActiveRules.output} -> + if output <> "" then print_endline output; + exit 0)) + | _ -> log_and_exit (Error (active_rules_help prog_name))) + | "show" :: rest -> ( + match rest with + | ["-h"] | ["--help"] -> log_and_exit (Ok (show_help prog_name)) + | path :: args -> ( + let rec parse_args kind context_path comments_mode = function + | [] -> Ok (kind, context_path, comments_mode) + | "--kind" :: value :: rest -> ( + match Tools.Show.show_kind_of_string value with + | Some kind -> parse_args kind context_path comments_mode rest + | None -> Error (show_help prog_name)) + | "--comments" :: value :: rest -> ( + match Tools.Show.comments_mode_of_string value with + | Some comments_mode -> + parse_args kind context_path comments_mode rest + | None -> Error (show_help prog_name)) + | "--context" :: path :: rest -> + parse_args kind (Some path) comments_mode rest + | _ -> Error (show_help prog_name) + in + match parse_args Tools.Show.Auto None Tools.Show.Include args with + | Error help -> log_and_exit (Error help) + | Ok (kind, context_path, comments_mode) -> ( + match Tools.Show.run ?context_path ~kind ~comments_mode path with + | Error err -> + prerr_endline err; + exit 2 + | Ok {Tools.Show.output} -> + if output <> "" then print_endline output; + exit 0)) + | _ -> log_and_exit (Error (show_help prog_name))) + | "find-references" :: rest -> ( + match rest with + | ["-h"] | ["--help"] -> log_and_exit (Ok (find_references_help prog_name)) + | args -> ( + let rec parse_args symbol_path kind context_path file_path line col = + function + | [] -> Ok (symbol_path, kind, context_path, file_path, line, col) + | "--kind" :: value :: rest -> ( + match Tools.Find_references.symbol_kind_of_string value with + | Some kind -> + parse_args symbol_path kind context_path file_path line col rest + | None -> Error (find_references_help prog_name)) + | "--context" :: path :: rest -> + parse_args symbol_path kind (Some path) file_path line col rest + | "--file" :: path :: rest -> + parse_args symbol_path kind context_path (Some path) line col rest + | "--line" :: value :: rest -> ( + match int_of_string_opt value with + | Some line -> + parse_args symbol_path kind context_path file_path (Some line) col + rest + | None -> Error (find_references_help prog_name)) + | "--col" :: value :: rest -> ( + match int_of_string_opt value with + | Some col -> + parse_args symbol_path kind context_path file_path line (Some col) + rest + | None -> Error (find_references_help prog_name)) + | value :: rest when not (String.starts_with ~prefix:"--" value) -> ( + match symbol_path with + | None -> + parse_args (Some value) kind context_path file_path line col rest + | Some _ -> Error (find_references_help prog_name)) + | _ -> Error (find_references_help prog_name) + in + match + parse_args None Tools.Find_references.Auto None None None None args + with + | Error help -> log_and_exit (Error help) + | Ok (symbol_path, kind, context_path, file_path, line, col) -> ( + let query = + match (symbol_path, file_path, line, col) with + | Some symbol_path, None, None, None -> + Some + (Tools.Find_references.Symbol {symbol_path; kind; context_path}) + | None, Some file_path, Some line, Some col -> + Some (Tools.Find_references.Location {file_path; line; col}) + | _ -> None + in + match query with + | None -> log_and_exit (Error (find_references_help prog_name)) + | Some query -> ( + match Tools.Find_references.run query with + | Error err -> + prerr_endline err; + exit 2 + | Ok {Tools.Find_references.output; _} -> + if output <> "" then print_endline output; + exit 0)))) + | ["-h"] | ["--help"] -> log_and_exit (Ok (help prog_name)) + | ["-v"] | ["--version"] -> log_and_exit (Ok version) + | _ -> log_and_exit (Error (help prog_name)) diff --git a/tools/bin/assist_main.ml b/tools/bin/assist_main.ml new file mode 100644 index 00000000000..81e8112d665 --- /dev/null +++ b/tools/bin/assist_main.ml @@ -0,0 +1 @@ +let () = Ai_cli.main ~prog_name:"rescript-assist" ~version:Version.version () diff --git a/tools/bin/dune b/tools/bin/dune index 674eca5b67d..64c7df1fa69 100644 --- a/tools/bin/dune +++ b/tools/bin/dune @@ -12,3 +12,12 @@ (libraries tools) (flags (:standard -w "+6+26+27+32+33+39"))) + +(executable + (public_name rescript-assist) + (package tools) + (modes byte exe) + (name assist_main) + (libraries tools) + (flags + (:standard -w "+6+26+27+32+33+39"))) diff --git a/tools/bin/main.ml b/tools/bin/main.ml index 79db5c5e553..47d5782f224 100644 --- a/tools/bin/main.ml +++ b/tools/bin/main.ml @@ -25,80 +25,6 @@ Usage: rescript-tools extract-codeblocks [--transform-assert-equal] Example: rescript-tools extract-codeblocks ./path/to/MyModule.res|} -let lintHelp = - {|ReScript Tools - -Run AI-oriented lint checks on a file or directory - -Usage: rescript-tools lint [--config ] [--json] - -Notes: -- Text is the default output format -- Use --json for compact machine-readable JSON -- Both source AST and typed information are used when available - -Example: rescript-tools lint ./src/MyModule.res|} - -let rewriteHelp = - {|ReScript Tools - -Rewrite a file or directory into a narrower agent-oriented source form - -Usage: rescript-tools rewrite [--config ] [--diff] [--json] - -Notes: -- Files are rewritten in place -- Use --diff to preview the rewritten diff without modifying files -- Use --json for compact machine-readable output -- Rewrite rules are loaded from the same config discovery path as lint - -Example: rescript-tools rewrite ./src/MyModule.res|} - -let activeRulesHelp = - {|ReScript Tools - -List lint and rewrite rules, whether they are currently active, and what they do - -Usage: rescript-tools active-rules [--config ] [--json] - -Notes: -- Uses the same config discovery path as lint and rewrite -- Includes both lint and rewrite rules -- Text is the default output format - -Example: rescript-tools active-rules ./src|} - -let showHelp = - {|ReScript Tools - -Show hover-style semantic information for a module, value, or type path - -Usage: rescript-tools show [--kind ] [--context ] [--comments ] - -Notes: -- Symbol paths are user-facing paths like String or String.localeCompare -- Context defaults to the current working directory -- Kind defaults to auto -- Comments default to include - -Example: rescript-tools show String.localeCompare|} - -let findReferencesHelp = - {|ReScript Tools - -Find references for a symbol path or for the symbol at a source location - -Usage: - rescript-tools find-references [--kind ] [--context ] - rescript-tools find-references --file --line --col - -Notes: -- Positional input means symbol-path mode -- Location mode uses 1-based line and column numbers -- Context defaults to the current working directory in symbol-path mode - -Example: rescript-tools find-references String.localeCompare|} - let help = {|ReScript Tools @@ -114,30 +40,13 @@ format-codeblocks Format ReScript code blocks [--transform-assert-equal] Transform `assertEqual` to `==` extract-codeblocks Extract ReScript code blocks from file [--transform-assert-equal] Transform `==` to `assertEqual` -lint Run AI-oriented lint checks - [--config ] Use the given lint config file - [--json] Output compact JSON -rewrite Rewrite source into agent-oriented normal form - [--config ] Use the given lint config file - [--diff] Preview the rewritten diff without writing files - [--json] Output compact JSON -active-rules List lint/rewrite rules and whether they are active - [--config ] Use the given lint config file - [--json] Output compact JSON -show Show hover-style semantic information - [--kind ] Select what kind of symbol to resolve - [--context ] Resolve within the package for this path - [--comments ] Include or omit doc/comments in output -find-references Find references by symbol path - [--kind ] Select what kind of symbol to resolve - [--context ] Resolve within the package for this path -find-references --file Find references at a source location - [--line ] Use 1-based line number - [--col ] Use 1-based column number reanalyze Reanalyze reanalyze-server Start reanalyze server -v, --version Print version --h, --help Print help|} +-h, --help Print help + +AI-oriented commands now live in the standalone `rescript-assist` binary: + lint, rewrite, active-rules, show, find-references|} let logAndExit = function | Ok log -> @@ -265,161 +174,9 @@ let main () = print_endline (Analysis.Protocol.stringifyResult r); exit 1) | _ -> logAndExit (Error extractCodeblocksHelp)) - | "lint" :: rest -> ( - match rest with - | ["-h"] | ["--help"] -> logAndExit (Ok lintHelp) - | path :: args -> ( - let rec parse_args config_path json = function - | [] -> Ok (config_path, json) - | "--json" :: rest -> parse_args config_path true rest - | "--config" :: config :: rest -> parse_args (Some config) json rest - | _ -> Error lintHelp - in - match parse_args None false args with - | Error help -> logAndExit (Error help) - | Ok (config_path, json) -> ( - match Tools.Lint.run ?config_path ~json path with - | Error err -> - prerr_endline err; - exit 2 - | Ok {Tools.Lint.output; has_findings} -> - if output <> "" then print_endline output; - exit (if has_findings then 1 else 0))) - | _ -> logAndExit (Error lintHelp)) - | "rewrite" :: rest -> ( - match rest with - | ["-h"] | ["--help"] -> logAndExit (Ok rewriteHelp) - | path :: args -> ( - let rec parse_args config_path json diff = function - | [] -> Ok (config_path, json, diff) - | "--json" :: rest -> parse_args config_path true diff rest - | "--diff" :: rest -> parse_args config_path json true rest - | "--config" :: config :: rest -> - parse_args (Some config) json diff rest - | _ -> Error rewriteHelp - in - match parse_args None false false args with - | Error help -> logAndExit (Error help) - | Ok (config_path, json, diff) -> ( - let mode = if diff then Tools.Rewrite.Diff else Tools.Rewrite.Write in - match Tools.Rewrite.run ?config_path ~json ~mode path with - | Error err -> - prerr_endline err; - exit 2 - | Ok {Tools.Rewrite.output; _} -> - if output <> "" then print_endline output; - exit 0)) - | _ -> logAndExit (Error rewriteHelp)) - | "active-rules" :: rest -> ( - match rest with - | ["-h"] | ["--help"] -> logAndExit (Ok activeRulesHelp) - | path :: args -> ( - let rec parse_args config_path json = function - | [] -> Ok (config_path, json) - | "--json" :: rest -> parse_args config_path true rest - | "--config" :: config :: rest -> parse_args (Some config) json rest - | _ -> Error activeRulesHelp - in - match parse_args None false args with - | Error help -> logAndExit (Error help) - | Ok (config_path, json) -> ( - match Tools.ActiveRules.run ?config_path ~json path with - | Error err -> - prerr_endline err; - exit 2 - | Ok {Tools.ActiveRules.output} -> - if output <> "" then print_endline output; - exit 0)) - | _ -> logAndExit (Error activeRulesHelp)) - | "show" :: rest -> ( - match rest with - | ["-h"] | ["--help"] -> logAndExit (Ok showHelp) - | path :: args -> ( - let rec parse_args kind context_path comments_mode = function - | [] -> Ok (kind, context_path, comments_mode) - | "--kind" :: value :: rest -> ( - match Tools.Show.show_kind_of_string value with - | Some kind -> parse_args kind context_path comments_mode rest - | None -> Error showHelp) - | "--comments" :: value :: rest -> ( - match Tools.Show.comments_mode_of_string value with - | Some comments_mode -> - parse_args kind context_path comments_mode rest - | None -> Error showHelp) - | "--context" :: path :: rest -> - parse_args kind (Some path) comments_mode rest - | _ -> Error showHelp - in - match parse_args Tools.Show.Auto None Tools.Show.Include args with - | Error help -> logAndExit (Error help) - | Ok (kind, context_path, comments_mode) -> ( - match Tools.Show.run ?context_path ~kind ~comments_mode path with - | Error err -> - prerr_endline err; - exit 2 - | Ok {Tools.Show.output} -> - if output <> "" then print_endline output; - exit 0)) - | _ -> logAndExit (Error showHelp)) - | "find-references" :: rest -> ( - match rest with - | ["-h"] | ["--help"] -> logAndExit (Ok findReferencesHelp) - | args -> ( - let rec parse_args symbol_path kind context_path file_path line col = - function - | [] -> Ok (symbol_path, kind, context_path, file_path, line, col) - | "--kind" :: value :: rest -> ( - match Tools.Find_references.symbol_kind_of_string value with - | Some kind -> - parse_args symbol_path kind context_path file_path line col rest - | None -> Error findReferencesHelp) - | "--context" :: path :: rest -> - parse_args symbol_path kind (Some path) file_path line col rest - | "--file" :: path :: rest -> - parse_args symbol_path kind context_path (Some path) line col rest - | "--line" :: value :: rest -> ( - match int_of_string_opt value with - | Some line -> - parse_args symbol_path kind context_path file_path (Some line) col - rest - | None -> Error findReferencesHelp) - | "--col" :: value :: rest -> ( - match int_of_string_opt value with - | Some col -> - parse_args symbol_path kind context_path file_path line (Some col) - rest - | None -> Error findReferencesHelp) - | value :: rest when not (String.starts_with ~prefix:"--" value) -> ( - match symbol_path with - | None -> - parse_args (Some value) kind context_path file_path line col rest - | Some _ -> Error findReferencesHelp) - | _ -> Error findReferencesHelp - in - match - parse_args None Tools.Find_references.Auto None None None None args - with - | Error help -> logAndExit (Error help) - | Ok (symbol_path, kind, context_path, file_path, line, col) -> ( - let query = - match (symbol_path, file_path, line, col) with - | Some symbol_path, None, None, None -> - Some - (Tools.Find_references.Symbol {symbol_path; kind; context_path}) - | None, Some file_path, Some line, Some col -> - Some (Tools.Find_references.Location {file_path; line; col}) - | _ -> None - in - match query with - | None -> logAndExit (Error findReferencesHelp) - | Some query -> ( - match Tools.Find_references.run query with - | Error err -> - prerr_endline err; - exit 2 - | Ok {Tools.Find_references.output; _} -> - if output <> "" then print_endline output; - exit 0)))) + | command :: _ when Ai_cli.is_ai_command command -> + logAndExit + (Error (Ai_cli.moved_command_message ~prog_name:"rescript-assist" command)) | "reanalyze" :: _ -> if Sys.getenv_opt "RESCRIPT_REANALYZE_NO_SERVER" = Some "1" then ( let len = Array.length Sys.argv in From 0115dbf7c1aa997cfdf385972b0c64b8aae9ca34 Mon Sep 17 00:00:00 2001 From: Gabriel Nordeborn Date: Mon, 20 Apr 2026 12:47:00 +0200 Subject: [PATCH 16/17] format --- cli/rescript-assist.js | 0 package.json | 2 +- yarn.lock | 1 + 3 files changed, 2 insertions(+), 1 deletion(-) mode change 100644 => 100755 cli/rescript-assist.js diff --git a/cli/rescript-assist.js b/cli/rescript-assist.js old mode 100644 new mode 100755 diff --git a/package.json b/package.json index 52aa2a0a4db..319c0fe29b0 100644 --- a/package.json +++ b/package.json @@ -45,8 +45,8 @@ }, "bin": { "bsc": "cli/bsc.js", - "rescript-assist": "cli/rescript-assist.js", "rescript": "cli/rescript.js", + "rescript-assist": "cli/rescript-assist.js", "rescript-tools": "cli/rescript-tools.js" }, "scripts": { diff --git a/yarn.lock b/yarn.lock index 84954c68b29..c945c83c3ee 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2458,6 +2458,7 @@ __metadata: bin: bsc: cli/bsc.js rescript: cli/rescript.js + rescript-assist: cli/rescript-assist.js rescript-tools: cli/rescript-tools.js languageName: unknown linkType: soft From 9b7a9252c143615a4d2b2abaf589d572b72021c0 Mon Sep 17 00:00:00 2001 From: Gabriel Nordeborn Date: Mon, 20 Apr 2026 12:55:08 +0200 Subject: [PATCH 17/17] restructure --- docs/rescript_ai.md | 48 +-- docs/skills/rescript-ai-template/SKILL.md | 16 +- tests/tools_tests/test.sh | 54 +-- tools/bin/ai_cli.ml | 455 +++++++++++++--------- tools/bin/main.ml | 5 +- 5 files changed, 337 insertions(+), 241 deletions(-) diff --git a/docs/rescript_ai.md b/docs/rescript_ai.md index 4237966632b..1eb9fba0cba 100644 --- a/docs/rescript_ai.md +++ b/docs/rescript_ai.md @@ -16,11 +16,11 @@ description stays current. Add the standalone `rescript-assist` CLI for AI-oriented workflows Commands -- `lint`: Runs configurable AI-oriented lint checks on a file or project root using source and typed information. -- `rewrite`: Rewrites source into a narrower agent-oriented normal form, with optional diff output. -- `active-rules`: Lists lint and rewrite rules, whether they are active, and how they are configured. -- `show`: Returns hover-style semantic information for a symbol path. -- `find-references`: Finds references from either a symbol path or a source location. +- `lint check`: Runs configurable AI-oriented lint checks on a file or project root using source and typed information. +- `rewrite run`: Rewrites source into a narrower agent-oriented normal form, with optional diff output. +- `support active-rules`: Lists lint and rewrite rules, whether they are active, and how they are configured. +- `support show`: Returns hover-style semantic information for a symbol path. +- `support find-references`: Finds references from either a symbol path or a source location. Initial rules - Lint: @@ -61,18 +61,18 @@ belong in the same tool surface. Recommended first shape: ```sh -rescript-assist lint [--config ] [--json] -rescript-assist rewrite [--config ] [--diff] [--json] -rescript-assist active-rules [--config ] [--json] -rescript-assist show [--kind ] [--context ] [--comments ] -rescript-assist find-references [--kind ] [--context ] -rescript-assist find-references --file --line --col +rescript-assist lint check [--config ] [--json] +rescript-assist rewrite run [--config ] [--diff] [--json] +rescript-assist support active-rules [--config ] [--json] +rescript-assist support show [--kind ] [--context ] [--comments ] +rescript-assist support find-references [--kind ] [--context ] +rescript-assist support find-references --file --line --col ``` Notes: -- `lint ` is the primary AI workflow -- `lint ` should walk project sources +- `lint check ` is the primary AI workflow +- `lint check ` should walk project sources - source AST analysis and typed analysis should run together as one lint pass - text should be the default output - `--json` should opt into compact JSON @@ -80,17 +80,18 @@ Notes: - `rewrite` is separate from `lint` - `rewrite` is allowed to be more aggressive because it is explicitly agent-oriented - lint reports style/semantic problems; rewrite canonicalizes source into a narrower normal form -- `rewrite --diff` should preview the rewritten diff without modifying files +- `rewrite run --diff` should preview the rewritten diff without modifying files - `rewrite` should emit a short summary of what changed after a write pass -- `active-rules` should list lint and rewrite rules, whether they are active, and what they do -- `show` should expose hover-style semantic lookup by symbol path instead of source position -- `show --comments omit` should make it easy to get a tighter, agent-oriented output -- `find-references` should support both symbol-path and source-location queries +- `support active-rules` should list lint and rewrite rules, whether they are active, and what they do +- `support show` should expose hover-style semantic lookup by symbol path instead of source position +- `support show --comments omit` should make it easy to get a tighter, agent-oriented output +- `support find-references` should support both symbol-path and source-location queries ## Recommended Placement - CLI entrypoints: `tools/bin/assist_main.ml` for `rescript-assist`, `tools/bin/main.ml` for `rescript-tools` -- command implementation: new module in `tools/src/` +- command dispatch: `tools/bin/ai_cli.ml` +- feature implementation: `tools/src/` - semantic loading and package resolution: reuse `analysis/src/Cmt.ml` - typedtree-derived structure/reference data: reuse `analysis/src/ProcessCmt.ml` - compact JSON helpers: likely reuse or extend `analysis/src/Protocol.ml` @@ -421,7 +422,7 @@ Aggressive source normalization for agents should be a separate command rather t Recommended first shape: ```sh -rescript-assist rewrite [--config ] [--diff] [--json] +rescript-assist rewrite run [--config ] [--diff] [--json] ``` Goal: @@ -655,9 +656,10 @@ This section is intentionally a scratchpad. Add ideas here freely before they ar ## Remaining Implementation Priorities Much of the initial bootstrapping work described earlier in this document is -now done. The current command surface already includes `lint`, `rewrite`, -`active-rules`, `show`, and `find-references`, along with namespaced -`lint`/`rewrite` config and the first rewrite rules. +now done. The current command surface already includes `lint check`, +`rewrite run`, and `support` subcommands for `active-rules`, `show`, and +`find-references`, along with namespaced `lint`/`rewrite` config and the first +rewrite rules. Near-term remaining work is mostly about hardening and narrowing the scope of future additions: diff --git a/docs/skills/rescript-ai-template/SKILL.md b/docs/skills/rescript-ai-template/SKILL.md index d32cbfec729..1e36a60ed50 100644 --- a/docs/skills/rescript-ai-template/SKILL.md +++ b/docs/skills/rescript-ai-template/SKILL.md @@ -1,6 +1,6 @@ --- name: rescript-ai-template -description: Template skill for agents working in a ReScript codebase with rescript-assist lint and related AI-oriented workflows. Use when creating a project-specific skill for file-by-file editing, lint-repair loops, and semantic checks. +description: Template skill for agents working in a ReScript codebase with rescript-assist lint check and related AI-oriented workflows. Use when creating a project-specific skill for file-by-file editing, lint-repair loops, and semantic checks. --- # ReScript AI Template @@ -12,7 +12,7 @@ Use this as a starting point for a project-specific skill. Replace placeholders Use this skill when: - editing `.res` or `.resi` files -- fixing lint findings from `rescript-assist lint` +- fixing lint findings from `rescript-assist lint check` - doing small file-first repair loops - checking semantic issues that depend on `.cmt/.cmti` @@ -20,7 +20,7 @@ Use this skill when: 1. Prefer a file-first loop: - edit one file - - run `rescript-assist lint ` + - run `rescript-assist lint check ` - fix findings - rerun lint for that file @@ -36,9 +36,9 @@ Use this skill when: ## Commands ```sh -rescript-assist lint -rescript-assist lint -rescript-assist lint --json +rescript-assist lint check +rescript-assist lint check +rescript-assist lint check --json ``` Optional project verification commands: @@ -86,7 +86,7 @@ Fill this section in for the actual repo: For one-file work: ```sh -rescript-assist lint src/File.res +rescript-assist lint check src/File.res ``` Fix the reported findings, then rerun the same command until clean. @@ -94,7 +94,7 @@ Fix the reported findings, then rerun the same command until clean. For broader fallout: ```sh -rescript-assist lint src/ +rescript-assist lint check src/ ``` ## Notes diff --git a/tests/tools_tests/test.sh b/tests/tools_tests/test.sh index 2d7000591fb..057cf9810f1 100755 --- a/tests/tools_tests/test.sh +++ b/tests/tools_tests/test.sh @@ -38,13 +38,13 @@ node -e "JSON.parse(require('node:fs').readFileSync('../../docs/docson/rescript- # Test lint command for file in src/lint/*.{res,resi}; do output="src/expected/$(basename $file).lint.expected" - ../../_build/install/default/bin/rescript-assist lint "$file" > "$output" || true + ../../_build/install/default/bin/rescript-assist lint check "$file" > "$output" || true if [ "$RUNNER_OS" == "Windows" ]; then perl -pi -e 's/\r\n/\n/g' -- "$output" fi json_output="src/expected/$(basename $file).lint.json.expected" - ../../_build/install/default/bin/rescript-assist lint "$file" --json > "$json_output" || true + ../../_build/install/default/bin/rescript-assist lint check "$file" --json > "$json_output" || true if [ "$RUNNER_OS" == "Windows" ]; then perl -pi -e 's/\r\n/\n/g' -- "$json_output" fi @@ -52,91 +52,91 @@ done generated_file="src/generated/GeneratedConsumer.res" generated_output="src/expected/$(basename $generated_file).lint.expected" -../../_build/install/default/bin/rescript-assist lint "$generated_file" > "$generated_output" || true +../../_build/install/default/bin/rescript-assist lint check "$generated_file" > "$generated_output" || true if [ "$RUNNER_OS" == "Windows" ]; then perl -pi -e 's/\r\n/\n/g' -- "$generated_output" fi generated_json_output="src/expected/$(basename $generated_file).lint.json.expected" -../../_build/install/default/bin/rescript-assist lint "$generated_file" --json > "$generated_json_output" || true +../../_build/install/default/bin/rescript-assist lint check "$generated_file" --json > "$generated_json_output" || true if [ "$RUNNER_OS" == "Windows" ]; then perl -pi -e 's/\r\n/\n/g' -- "$generated_json_output" fi unbuilt_file="unbuilt/ForbiddenExplicitNoCmt.res" unbuilt_output="src/expected/$(basename $unbuilt_file).lint.expected" -../../_build/install/default/bin/rescript-assist lint "$unbuilt_file" > "$unbuilt_output" || true +../../_build/install/default/bin/rescript-assist lint check "$unbuilt_file" > "$unbuilt_output" || true if [ "$RUNNER_OS" == "Windows" ]; then perl -pi -e 's/\r\n/\n/g' -- "$unbuilt_output" fi unbuilt_json_output="src/expected/$(basename $unbuilt_file).lint.json.expected" -../../_build/install/default/bin/rescript-assist lint "$unbuilt_file" --json > "$unbuilt_json_output" || true +../../_build/install/default/bin/rescript-assist lint check "$unbuilt_file" --json > "$unbuilt_json_output" || true if [ "$RUNNER_OS" == "Windows" ]; then perl -pi -e 's/\r\n/\n/g' -- "$unbuilt_json_output" fi -../../_build/install/default/bin/rescript-assist lint src/lint > src/expected/lint-root.expected || true +../../_build/install/default/bin/rescript-assist lint check src/lint > src/expected/lint-root.expected || true if [ "$RUNNER_OS" == "Windows" ]; then perl -pi -e 's/\r\n/\n/g' -- src/expected/lint-root.expected fi -../../_build/install/default/bin/rescript-assist lint src/lint --json > src/expected/lint-root.json.expected || true +../../_build/install/default/bin/rescript-assist lint check src/lint --json > src/expected/lint-root.json.expected || true if [ "$RUNNER_OS" == "Windows" ]; then perl -pi -e 's/\r\n/\n/g' -- src/expected/lint-root.json.expected fi # Test active-rules command -../../_build/install/default/bin/rescript-assist active-rules src/lint > src/expected/active-rules.expected || exit 1 +../../_build/install/default/bin/rescript-assist support active-rules src/lint > src/expected/active-rules.expected || exit 1 if [ "$RUNNER_OS" == "Windows" ]; then perl -pi -e 's/\r\n/\n/g' -- src/expected/active-rules.expected fi -../../_build/install/default/bin/rescript-assist active-rules src/lint --json > src/expected/active-rules.json.expected || exit 1 +../../_build/install/default/bin/rescript-assist support active-rules src/lint --json > src/expected/active-rules.json.expected || exit 1 if [ "$RUNNER_OS" == "Windows" ]; then perl -pi -e 's/\r\n/\n/g' -- src/expected/active-rules.json.expected fi # Test show command -../../_build/install/default/bin/rescript-assist show ShowFixture --kind module > src/expected/show-ShowFixture.expected || exit 1 +../../_build/install/default/bin/rescript-assist support show ShowFixture --kind module > src/expected/show-ShowFixture.expected || exit 1 if [ "$RUNNER_OS" == "Windows" ]; then perl -pi -e 's/\r\n/\n/g' -- src/expected/show-ShowFixture.expected fi -../../_build/install/default/bin/rescript-assist show ShowFixture.Nested.makeGreeting --kind value > src/expected/show-ShowFixture.Nested.makeGreeting.expected || exit 1 +../../_build/install/default/bin/rescript-assist support show ShowFixture.Nested.makeGreeting --kind value > src/expected/show-ShowFixture.Nested.makeGreeting.expected || exit 1 if [ "$RUNNER_OS" == "Windows" ]; then perl -pi -e 's/\r\n/\n/g' -- src/expected/show-ShowFixture.Nested.makeGreeting.expected fi -../../_build/install/default/bin/rescript-assist show ShowFixture.Nested.makeGreeting --kind value --comments omit > src/expected/show-ShowFixture.Nested.makeGreeting.no-comments.expected || exit 1 +../../_build/install/default/bin/rescript-assist support show ShowFixture.Nested.makeGreeting --kind value --comments omit > src/expected/show-ShowFixture.Nested.makeGreeting.no-comments.expected || exit 1 if [ "$RUNNER_OS" == "Windows" ]; then perl -pi -e 's/\r\n/\n/g' -- src/expected/show-ShowFixture.Nested.makeGreeting.no-comments.expected fi -../../_build/install/default/bin/rescript-assist show ShowFixture.item --kind type > src/expected/show-ShowFixture.item.expected || exit 1 +../../_build/install/default/bin/rescript-assist support show ShowFixture.item --kind type > src/expected/show-ShowFixture.item.expected || exit 1 if [ "$RUNNER_OS" == "Windows" ]; then perl -pi -e 's/\r\n/\n/g' -- src/expected/show-ShowFixture.item.expected fi -../../_build/install/default/bin/rescript-assist show String.localeCompare > src/expected/show-String.localeCompare.expected || exit 1 +../../_build/install/default/bin/rescript-assist support show String.localeCompare > src/expected/show-String.localeCompare.expected || exit 1 if [ "$RUNNER_OS" == "Windows" ]; then perl -pi -e 's/\r\n/\n/g' -- src/expected/show-String.localeCompare.expected fi -../../_build/install/default/bin/rescript-assist show String --kind module > /dev/null || exit 1 +../../_build/install/default/bin/rescript-assist support show String --kind module > /dev/null || exit 1 # Test find-references command -../../_build/install/default/bin/rescript-assist find-references FindReferencesFixture.makeGreeting --kind value > src/expected/find-references-FindReferencesFixture.makeGreeting.expected || exit 1 +../../_build/install/default/bin/rescript-assist support find-references FindReferencesFixture.makeGreeting --kind value > src/expected/find-references-FindReferencesFixture.makeGreeting.expected || exit 1 if [ "$RUNNER_OS" == "Windows" ]; then perl -pi -e 's/\r\n/\n/g' -- src/expected/find-references-FindReferencesFixture.makeGreeting.expected fi -../../_build/install/default/bin/rescript-assist find-references --file src/find_references/FindReferencesUse.res --line 4 --col 35 > src/expected/find-references-location.expected || exit 1 +../../_build/install/default/bin/rescript-assist support find-references --file src/find_references/FindReferencesUse.res --line 4 --col 35 > src/expected/find-references-location.expected || exit 1 if [ "$RUNNER_OS" == "Windows" ]; then perl -pi -e 's/\r\n/\n/g' -- src/expected/find-references-location.expected fi -../../_build/install/default/bin/rescript-assist find-references FindReferencesFixture --kind module > /dev/null || exit 1 +../../_build/install/default/bin/rescript-assist support find-references FindReferencesFixture --kind module > /dev/null || exit 1 # Test rewrite command rm -rf .tmp-rewrite-tests @@ -147,7 +147,7 @@ for file in src/rewrite/*.{res,resi}; do cp "$file" "$tmp_file" output="src/expected/$(basename $file).rewrite.expected" - ../../_build/install/default/bin/rescript-assist rewrite "$tmp_file" > "$output" + ../../_build/install/default/bin/rescript-assist rewrite run "$tmp_file" > "$output" if [ "$RUNNER_OS" == "Windows" ]; then perl -pi -e 's/\r\n/\n/g' -- "$output" fi @@ -160,19 +160,19 @@ for file in src/rewrite/*.{res,resi}; do cp "$file" "$tmp_file" json_output="src/expected/$(basename $file).rewrite.json.expected" - ../../_build/install/default/bin/rescript-assist rewrite "$tmp_file" --json > "$json_output" + ../../_build/install/default/bin/rescript-assist rewrite run "$tmp_file" --json > "$json_output" if [ "$RUNNER_OS" == "Windows" ]; then perl -pi -e 's/\r\n/\n/g' -- "$json_output" fi diff_output="src/expected/$(basename $file).rewrite.diff.expected" - ../../_build/install/default/bin/rescript-assist rewrite "$file" --diff > "$diff_output" + ../../_build/install/default/bin/rescript-assist rewrite run "$file" --diff > "$diff_output" if [ "$RUNNER_OS" == "Windows" ]; then perl -pi -e 's/\r\n/\n/g' -- "$diff_output" fi diff_json_output="src/expected/$(basename $file).rewrite.diff.json.expected" - ../../_build/install/default/bin/rescript-assist rewrite "$file" --diff --json > "$diff_json_output" + ../../_build/install/default/bin/rescript-assist rewrite run "$file" --diff --json > "$diff_json_output" if [ "$RUNNER_OS" == "Windows" ]; then perl -pi -e 's/\r\n/\n/g' -- "$diff_json_output" fi @@ -181,7 +181,7 @@ done rm -rf .tmp-rewrite-tests/root mkdir -p .tmp-rewrite-tests/root cp src/rewrite/*.{res,resi} .tmp-rewrite-tests/root/ -../../_build/install/default/bin/rescript-assist rewrite .tmp-rewrite-tests/root > src/expected/rewrite-root.expected +../../_build/install/default/bin/rescript-assist rewrite run .tmp-rewrite-tests/root > src/expected/rewrite-root.expected if [ "$RUNNER_OS" == "Windows" ]; then perl -pi -e 's/\r\n/\n/g' -- src/expected/rewrite-root.expected fi @@ -189,17 +189,17 @@ fi rm -rf .tmp-rewrite-tests/root mkdir -p .tmp-rewrite-tests/root cp src/rewrite/*.{res,resi} .tmp-rewrite-tests/root/ -../../_build/install/default/bin/rescript-assist rewrite .tmp-rewrite-tests/root --json > src/expected/rewrite-root.json.expected +../../_build/install/default/bin/rescript-assist rewrite run .tmp-rewrite-tests/root --json > src/expected/rewrite-root.json.expected if [ "$RUNNER_OS" == "Windows" ]; then perl -pi -e 's/\r\n/\n/g' -- src/expected/rewrite-root.json.expected fi -../../_build/install/default/bin/rescript-assist rewrite src/rewrite --diff > src/expected/rewrite-root.diff.expected +../../_build/install/default/bin/rescript-assist rewrite run src/rewrite --diff > src/expected/rewrite-root.diff.expected if [ "$RUNNER_OS" == "Windows" ]; then perl -pi -e 's/\r\n/\n/g' -- src/expected/rewrite-root.diff.expected fi -../../_build/install/default/bin/rescript-assist rewrite src/rewrite --diff --json > src/expected/rewrite-root.diff.json.expected +../../_build/install/default/bin/rescript-assist rewrite run src/rewrite --diff --json > src/expected/rewrite-root.diff.json.expected if [ "$RUNNER_OS" == "Windows" ]; then perl -pi -e 's/\r\n/\n/g' -- src/expected/rewrite-root.diff.json.expected fi diff --git a/tools/bin/ai_cli.ml b/tools/bin/ai_cli.ml index e1cdb7a8336..385914eff6e 100644 --- a/tools/bin/ai_cli.ml +++ b/tools/bin/ai_cli.ml @@ -8,29 +8,46 @@ let log_and_exit = function let banner = "ReScript Assist" -let lint_help prog_name = +let lint_check_help prog_name = Printf.sprintf {|%s Run AI-oriented lint checks on a file or directory -Usage: %s lint [--config ] [--json] +Usage: %s lint check [--config ] [--json] Notes: - Text is the default output format - Use --json for compact machine-readable JSON - Both source AST and typed information are used when available -Example: %s lint ./src/MyModule.res|} +Example: %s lint check ./src/MyModule.res|} banner prog_name prog_name -let rewrite_help prog_name = +let lint_help prog_name = + Printf.sprintf + {|%s + +Run AI-oriented lint commands + +Usage: %s lint + +Commands: + +check Run AI-oriented lint checks + [--config ] Use the given lint config file + [--json] Output compact JSON + +Example: %s lint check ./src/MyModule.res|} + banner prog_name prog_name + +let rewrite_run_help prog_name = Printf.sprintf {|%s Rewrite a file or directory into a narrower agent-oriented source form -Usage: %s rewrite [--config ] [--diff] [--json] +Usage: %s rewrite run [--config ] [--diff] [--json] Notes: - Files are rewritten in place @@ -38,7 +55,25 @@ Notes: - Use --json for compact machine-readable output - Rewrite rules are loaded from the same config discovery path as lint -Example: %s rewrite ./src/MyModule.res|} +Example: %s rewrite run ./src/MyModule.res|} + banner prog_name prog_name + +let rewrite_help prog_name = + Printf.sprintf + {|%s + +Run AI-oriented rewrite commands + +Usage: %s rewrite + +Commands: + +run Rewrite source into agent-oriented normal form + [--config ] Use the given lint config file + [--diff] Preview the rewritten diff without writing files + [--json] Output compact JSON + +Example: %s rewrite run ./src/MyModule.res|} banner prog_name prog_name let active_rules_help prog_name = @@ -47,14 +82,14 @@ let active_rules_help prog_name = List lint and rewrite rules, whether they are currently active, and what they do -Usage: %s active-rules [--config ] [--json] +Usage: %s support active-rules [--config ] [--json] Notes: - Uses the same config discovery path as lint and rewrite - Includes both lint and rewrite rules - Text is the default output format -Example: %s active-rules ./src|} +Example: %s support active-rules ./src|} banner prog_name prog_name let show_help prog_name = @@ -63,7 +98,7 @@ let show_help prog_name = Show hover-style semantic information for a module, value, or type path -Usage: %s show [--kind ] [--context ] [--comments ] +Usage: %s support show [--kind ] [--context ] [--comments ] Notes: - Symbol paths are user-facing paths like String or String.localeCompare @@ -71,7 +106,7 @@ Notes: - Kind defaults to auto - Comments default to include -Example: %s show String.localeCompare|} +Example: %s support show String.localeCompare|} banner prog_name prog_name let find_references_help prog_name = @@ -81,32 +116,27 @@ let find_references_help prog_name = Find references for a symbol path or for the symbol at a source location Usage: - %s find-references [--kind ] [--context ] - %s find-references --file --line --col + %s support find-references [--kind ] [--context ] + %s support find-references --file --line --col Notes: - Positional input means symbol-path mode - Location mode uses 1-based line and column numbers - Context defaults to the current working directory in symbol-path mode -Example: %s find-references String.localeCompare|} +Example: %s support find-references String.localeCompare|} banner prog_name prog_name prog_name -let help prog_name = +let support_help prog_name = Printf.sprintf {|%s -Usage: %s [command] +Run AI-oriented support commands + +Usage: %s support Commands: -lint Run AI-oriented lint checks - [--config ] Use the given lint config file - [--json] Output compact JSON -rewrite Rewrite source into agent-oriented normal form - [--config ] Use the given lint config file - [--diff] Preview the rewritten diff without writing files - [--json] Output compact JSON active-rules List lint/rewrite rules and whether they are active [--config ] Use the given lint config file [--json] Output compact JSON @@ -120,177 +150,244 @@ find-references Find references by symbol path find-references --file Find references at a source location [--line ] Use 1-based line number [--col ] Use 1-based column number + +Example: %s support active-rules ./src|} + banner prog_name prog_name + +let help prog_name = + Printf.sprintf + {|%s + +Usage: %s + +Namespaces: + +lint + check Run AI-oriented lint checks +rewrite + run Rewrite source into agent-oriented normal form +support + active-rules List lint/rewrite rules and whether they are active + show Show hover-style semantic information + find-references Find references by symbol path or source location -v, --version Print version -h, --help Print help|} banner prog_name -let command_names = - ["lint"; "rewrite"; "active-rules"; "show"; "find-references"] +let top_level_commands = + ["lint"; "rewrite"; "support"; "active-rules"; "show"; "find-references"] -let is_ai_command command = List.mem command command_names +let is_ai_command command = List.mem command top_level_commands + +let moved_command_invocation command = + match command with + | "lint" -> "lint check " + | "rewrite" -> "rewrite run " + | "support" -> "support --help" + | "active-rules" -> "support active-rules " + | "show" -> "support show " + | "find-references" -> "support find-references " + | other -> other let moved_command_message ~prog_name command = Printf.sprintf "error: `%s` has moved to the standalone `%s` binary.\n\nUse:\n %s %s" - command prog_name prog_name command - -let main ~prog_name ~version () = - match Sys.argv |> Array.to_list |> List.tl with - | "lint" :: rest -> ( - match rest with - | ["-h"] | ["--help"] -> log_and_exit (Ok (lint_help prog_name)) - | path :: args -> ( - let rec parse_args config_path json = function - | [] -> Ok (config_path, json) - | "--json" :: rest -> parse_args config_path true rest - | "--config" :: config :: rest -> parse_args (Some config) json rest - | _ -> Error (lint_help prog_name) - in - match parse_args None false args with - | Error help -> log_and_exit (Error help) - | Ok (config_path, json) -> ( - match Tools.Lint.run ?config_path ~json path with - | Error err -> - prerr_endline err; - exit 2 - | Ok {Tools.Lint.output; has_findings} -> - if output <> "" then print_endline output; - exit (if has_findings then 1 else 0))) - | _ -> log_and_exit (Error (lint_help prog_name))) - | "rewrite" :: rest -> ( - match rest with - | ["-h"] | ["--help"] -> log_and_exit (Ok (rewrite_help prog_name)) - | path :: args -> ( - let rec parse_args config_path json diff = function - | [] -> Ok (config_path, json, diff) - | "--json" :: rest -> parse_args config_path true diff rest - | "--diff" :: rest -> parse_args config_path json true rest - | "--config" :: config :: rest -> - parse_args (Some config) json diff rest - | _ -> Error (rewrite_help prog_name) + command prog_name prog_name + (moved_command_invocation command) + +let namespace_command_message ~prog_name ~namespace ~command = + Printf.sprintf "error: `%s` is now a namespace.\n\nUse:\n %s %s %s" namespace + prog_name namespace command + +let run_lint_check ~prog_name = function + | ["-h"] | ["--help"] -> log_and_exit (Ok (lint_check_help prog_name)) + | path :: args -> ( + let rec parse_args config_path json = function + | [] -> Ok (config_path, json) + | "--json" :: rest -> parse_args config_path true rest + | "--config" :: config :: rest -> parse_args (Some config) json rest + | _ -> Error (lint_check_help prog_name) + in + match parse_args None false args with + | Error help -> log_and_exit (Error help) + | Ok (config_path, json) -> ( + match Tools.Lint.run ?config_path ~json path with + | Error err -> + prerr_endline err; + exit 2 + | Ok {Tools.Lint.output; has_findings} -> + if output <> "" then print_endline output; + exit (if has_findings then 1 else 0))) + | _ -> log_and_exit (Error (lint_check_help prog_name)) + +let run_rewrite ~prog_name = function + | ["-h"] | ["--help"] -> log_and_exit (Ok (rewrite_run_help prog_name)) + | path :: args -> ( + let rec parse_args config_path json diff = function + | [] -> Ok (config_path, json, diff) + | "--json" :: rest -> parse_args config_path true diff rest + | "--diff" :: rest -> parse_args config_path json true rest + | "--config" :: config :: rest -> parse_args (Some config) json diff rest + | _ -> Error (rewrite_run_help prog_name) + in + match parse_args None false false args with + | Error help -> log_and_exit (Error help) + | Ok (config_path, json, diff) -> ( + let mode = if diff then Tools.Rewrite.Diff else Tools.Rewrite.Write in + match Tools.Rewrite.run ?config_path ~json ~mode path with + | Error err -> + prerr_endline err; + exit 2 + | Ok {Tools.Rewrite.output; _} -> + if output <> "" then print_endline output; + exit 0)) + | _ -> log_and_exit (Error (rewrite_run_help prog_name)) + +let run_active_rules ~prog_name = function + | ["-h"] | ["--help"] -> log_and_exit (Ok (active_rules_help prog_name)) + | path :: args -> ( + let rec parse_args config_path json = function + | [] -> Ok (config_path, json) + | "--json" :: rest -> parse_args config_path true rest + | "--config" :: config :: rest -> parse_args (Some config) json rest + | _ -> Error (active_rules_help prog_name) + in + match parse_args None false args with + | Error help -> log_and_exit (Error help) + | Ok (config_path, json) -> ( + match Tools.ActiveRules.run ?config_path ~json path with + | Error err -> + prerr_endline err; + exit 2 + | Ok {Tools.ActiveRules.output} -> + if output <> "" then print_endline output; + exit 0)) + | _ -> log_and_exit (Error (active_rules_help prog_name)) + +let run_show ~prog_name = function + | ["-h"] | ["--help"] -> log_and_exit (Ok (show_help prog_name)) + | path :: args -> ( + let rec parse_args kind context_path comments_mode = function + | [] -> Ok (kind, context_path, comments_mode) + | "--kind" :: value :: rest -> ( + match Tools.Show.show_kind_of_string value with + | Some kind -> parse_args kind context_path comments_mode rest + | None -> Error (show_help prog_name)) + | "--comments" :: value :: rest -> ( + match Tools.Show.comments_mode_of_string value with + | Some comments_mode -> parse_args kind context_path comments_mode rest + | None -> Error (show_help prog_name)) + | "--context" :: path :: rest -> + parse_args kind (Some path) comments_mode rest + | _ -> Error (show_help prog_name) + in + match parse_args Tools.Show.Auto None Tools.Show.Include args with + | Error help -> log_and_exit (Error help) + | Ok (kind, context_path, comments_mode) -> ( + match Tools.Show.run ?context_path ~kind ~comments_mode path with + | Error err -> + prerr_endline err; + exit 2 + | Ok {Tools.Show.output} -> + if output <> "" then print_endline output; + exit 0)) + | _ -> log_and_exit (Error (show_help prog_name)) + +let run_find_references ~prog_name = function + | ["-h"] | ["--help"] -> log_and_exit (Ok (find_references_help prog_name)) + | args -> ( + let rec parse_args symbol_path kind context_path file_path line col = + function + | [] -> Ok (symbol_path, kind, context_path, file_path, line, col) + | "--kind" :: value :: rest -> ( + match Tools.Find_references.symbol_kind_of_string value with + | Some kind -> + parse_args symbol_path kind context_path file_path line col rest + | None -> Error (find_references_help prog_name)) + | "--context" :: path :: rest -> + parse_args symbol_path kind (Some path) file_path line col rest + | "--file" :: path :: rest -> + parse_args symbol_path kind context_path (Some path) line col rest + | "--line" :: value :: rest -> ( + match int_of_string_opt value with + | Some line -> + parse_args symbol_path kind context_path file_path (Some line) col + rest + | None -> Error (find_references_help prog_name)) + | "--col" :: value :: rest -> ( + match int_of_string_opt value with + | Some col -> + parse_args symbol_path kind context_path file_path line (Some col) + rest + | None -> Error (find_references_help prog_name)) + | value :: rest when not (String.starts_with ~prefix:"--" value) -> ( + match symbol_path with + | None -> + parse_args (Some value) kind context_path file_path line col rest + | Some _ -> Error (find_references_help prog_name)) + | _ -> Error (find_references_help prog_name) + in + match + parse_args None Tools.Find_references.Auto None None None None args + with + | Error help -> log_and_exit (Error help) + | Ok (symbol_path, kind, context_path, file_path, line, col) -> ( + let query = + match (symbol_path, file_path, line, col) with + | Some symbol_path, None, None, None -> + Some (Tools.Find_references.Symbol {symbol_path; kind; context_path}) + | None, Some file_path, Some line, Some col -> + Some (Tools.Find_references.Location {file_path; line; col}) + | _ -> None in - match parse_args None false false args with - | Error help -> log_and_exit (Error help) - | Ok (config_path, json, diff) -> ( - let mode = if diff then Tools.Rewrite.Diff else Tools.Rewrite.Write in - match Tools.Rewrite.run ?config_path ~json ~mode path with + match query with + | None -> log_and_exit (Error (find_references_help prog_name)) + | Some query -> ( + match Tools.Find_references.run query with | Error err -> prerr_endline err; exit 2 - | Ok {Tools.Rewrite.output; _} -> + | Ok {Tools.Find_references.output; _} -> if output <> "" then print_endline output; - exit 0)) - | _ -> log_and_exit (Error (rewrite_help prog_name))) - | "active-rules" :: rest -> ( - match rest with - | ["-h"] | ["--help"] -> log_and_exit (Ok (active_rules_help prog_name)) - | path :: args -> ( - let rec parse_args config_path json = function - | [] -> Ok (config_path, json) - | "--json" :: rest -> parse_args config_path true rest - | "--config" :: config :: rest -> parse_args (Some config) json rest - | _ -> Error (active_rules_help prog_name) - in - match parse_args None false args with - | Error help -> log_and_exit (Error help) - | Ok (config_path, json) -> ( - match Tools.ActiveRules.run ?config_path ~json path with - | Error err -> - prerr_endline err; - exit 2 - | Ok {Tools.ActiveRules.output} -> - if output <> "" then print_endline output; - exit 0)) - | _ -> log_and_exit (Error (active_rules_help prog_name))) - | "show" :: rest -> ( - match rest with - | ["-h"] | ["--help"] -> log_and_exit (Ok (show_help prog_name)) - | path :: args -> ( - let rec parse_args kind context_path comments_mode = function - | [] -> Ok (kind, context_path, comments_mode) - | "--kind" :: value :: rest -> ( - match Tools.Show.show_kind_of_string value with - | Some kind -> parse_args kind context_path comments_mode rest - | None -> Error (show_help prog_name)) - | "--comments" :: value :: rest -> ( - match Tools.Show.comments_mode_of_string value with - | Some comments_mode -> - parse_args kind context_path comments_mode rest - | None -> Error (show_help prog_name)) - | "--context" :: path :: rest -> - parse_args kind (Some path) comments_mode rest - | _ -> Error (show_help prog_name) - in - match parse_args Tools.Show.Auto None Tools.Show.Include args with - | Error help -> log_and_exit (Error help) - | Ok (kind, context_path, comments_mode) -> ( - match Tools.Show.run ?context_path ~kind ~comments_mode path with - | Error err -> - prerr_endline err; - exit 2 - | Ok {Tools.Show.output} -> - if output <> "" then print_endline output; - exit 0)) - | _ -> log_and_exit (Error (show_help prog_name))) - | "find-references" :: rest -> ( - match rest with - | ["-h"] | ["--help"] -> log_and_exit (Ok (find_references_help prog_name)) - | args -> ( - let rec parse_args symbol_path kind context_path file_path line col = - function - | [] -> Ok (symbol_path, kind, context_path, file_path, line, col) - | "--kind" :: value :: rest -> ( - match Tools.Find_references.symbol_kind_of_string value with - | Some kind -> - parse_args symbol_path kind context_path file_path line col rest - | None -> Error (find_references_help prog_name)) - | "--context" :: path :: rest -> - parse_args symbol_path kind (Some path) file_path line col rest - | "--file" :: path :: rest -> - parse_args symbol_path kind context_path (Some path) line col rest - | "--line" :: value :: rest -> ( - match int_of_string_opt value with - | Some line -> - parse_args symbol_path kind context_path file_path (Some line) col - rest - | None -> Error (find_references_help prog_name)) - | "--col" :: value :: rest -> ( - match int_of_string_opt value with - | Some col -> - parse_args symbol_path kind context_path file_path line (Some col) - rest - | None -> Error (find_references_help prog_name)) - | value :: rest when not (String.starts_with ~prefix:"--" value) -> ( - match symbol_path with - | None -> - parse_args (Some value) kind context_path file_path line col rest - | Some _ -> Error (find_references_help prog_name)) - | _ -> Error (find_references_help prog_name) - in - match - parse_args None Tools.Find_references.Auto None None None None args - with - | Error help -> log_and_exit (Error help) - | Ok (symbol_path, kind, context_path, file_path, line, col) -> ( - let query = - match (symbol_path, file_path, line, col) with - | Some symbol_path, None, None, None -> - Some - (Tools.Find_references.Symbol {symbol_path; kind; context_path}) - | None, Some file_path, Some line, Some col -> - Some (Tools.Find_references.Location {file_path; line; col}) - | _ -> None - in - match query with - | None -> log_and_exit (Error (find_references_help prog_name)) - | Some query -> ( - match Tools.Find_references.run query with - | Error err -> - prerr_endline err; - exit 2 - | Ok {Tools.Find_references.output; _} -> - if output <> "" then print_endline output; - exit 0)))) + exit 0))) + +let lint_namespace_help_or_redirect ~prog_name = function + | [] | ["-h"] | ["--help"] -> log_and_exit (Ok (lint_help prog_name)) + | "check" :: rest -> run_lint_check ~prog_name rest + | _ -> + log_and_exit + (Error + (namespace_command_message ~prog_name ~namespace:"lint" + ~command:"check ")) + +let rewrite_namespace_help_or_redirect ~prog_name = function + | [] | ["-h"] | ["--help"] -> log_and_exit (Ok (rewrite_help prog_name)) + | "run" :: rest -> run_rewrite ~prog_name rest + | _ -> + log_and_exit + (Error + (namespace_command_message ~prog_name ~namespace:"rewrite" + ~command:"run ")) + +let support_namespace_help_or_redirect ~prog_name = function + | [] | ["-h"] | ["--help"] -> log_and_exit (Ok (support_help prog_name)) + | "active-rules" :: rest -> run_active_rules ~prog_name rest + | "show" :: rest -> run_show ~prog_name rest + | "find-references" :: rest -> run_find_references ~prog_name rest + | _ -> log_and_exit (Error (support_help prog_name)) + +let main ~prog_name ~version () = + match Sys.argv |> Array.to_list |> List.tl with + | "lint" :: rest -> lint_namespace_help_or_redirect ~prog_name rest + | "rewrite" :: rest -> rewrite_namespace_help_or_redirect ~prog_name rest + | "support" :: rest -> support_namespace_help_or_redirect ~prog_name rest + | (("active-rules" | "show" | "find-references") as command) :: _ -> + log_and_exit + (Error + (Printf.sprintf + "error: `%s` is now under the `support` namespace.\n\nUse:\n %s %s" + command prog_name + (moved_command_invocation command))) | ["-h"] | ["--help"] -> log_and_exit (Ok (help prog_name)) | ["-v"] | ["--version"] -> log_and_exit (Ok version) | _ -> log_and_exit (Error (help prog_name)) diff --git a/tools/bin/main.ml b/tools/bin/main.ml index 47d5782f224..511e704ce66 100644 --- a/tools/bin/main.ml +++ b/tools/bin/main.ml @@ -43,10 +43,7 @@ extract-codeblocks Extract ReScript code blocks from file reanalyze Reanalyze reanalyze-server Start reanalyze server -v, --version Print version --h, --help Print help - -AI-oriented commands now live in the standalone `rescript-assist` binary: - lint, rewrite, active-rules, show, find-references|} +-h, --help Print help|} let logAndExit = function | Ok log ->