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 100755 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/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/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 new file mode 100644 index 00000000000..1eb9fba0cba --- /dev/null +++ b/docs/rescript_ai.md @@ -0,0 +1,694 @@ +# 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: 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`. + +## PR description + +Keep this section updated as the command surface and rule set change so the PR +description stays current. + +```text +Add the standalone `rescript-assist` CLI for AI-oriented workflows + +Commands +- `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: + - `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, golden tests, and the standalone `rescript-assist` binary +``` + +## 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 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 + +Recommended first shape: + +```sh +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 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 +- 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 +- `rewrite run --diff` should preview the rewritten diff without modifying files +- `rewrite` should emit a short summary of what changed after a write pass +- `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 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` + +## 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` + +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": [ + { + "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." + } + ] + } + ], + "forbidden-source-root-reference": { + "severity": "error", + "roots": ["src/generated"], + "kinds": ["value", "type"] + }, + "single-use-function": { + "severity": "warning" + } + } + } +} +``` + +`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 + +### 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. + +### 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 +{ + "$schema": "./node_modules/rescript/docs/docson/rescript-lint-schema.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. + +### 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 + +### FFI shape + +- 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 +- 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 + +### Preferred type syntax + +- enforce canonical builtin type syntax 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. + +Recommended first shape: + +```sh +rescript-assist rewrite run [--config ] [--diff] [--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. + +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": [ + { "kind": "value", "path": "Belt.Array.forEach" }, + { "kind": "value", "path": "Belt.Array.map" }, + { "kind": "type", "path": "Js.Json.t" } + ] + }, + "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 }, + "preferred-type-syntax": { "enabled": true, "dict": true } + } + } +} +``` + +### 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: `~label=?Some(expr)` -> `~label=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 +- 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 + - 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" + +## Remaining Implementation Priorities + +Much of the initial bootstrapping work described earlier in this document is +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: + +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 + +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/docs/skills/rescript-ai-template/SKILL.md b/docs/skills/rescript-ai-template/SKILL.md new file mode 100644 index 00000000000..1e36a60ed50 --- /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-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 + +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-assist lint check` +- 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-assist lint check ` + - 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-assist lint check +rescript-assist lint check +rescript-assist lint check --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-assist lint check src/File.res +``` + +Fix the reported findings, then rerun the same command until clean. + +For broader fallout: + +```sh +rescript-assist lint check 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. diff --git a/package.json b/package.json index 115298467d4..319c0fe29b0 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "bin": { "bsc": "cli/bsc.js", "rescript": "cli/rescript.js", + "rescript-assist": "cli/rescript-assist.js", "rescript-tools": "cli/rescript-tools.js" }, "scripts": { @@ -65,6 +66,7 @@ "COPYING.LESSER", "CREDITS.md", "docs/docson/build-schema.json", + "docs/docson/rescript-lint-schema.json", "cli" ], "exports": { 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/.rescript-lint.json b/tests/tools_tests/.rescript-lint.json new file mode 100644 index 00000000000..e2e81e52748 --- /dev/null +++ b/tests/tools_tests/.rescript-lint.json @@ -0,0 +1,71 @@ +{ + "$schema": "../../docs/docson/rescript-lint-schema.json", + "lint": { + "rules": { + "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" + }, + "forbidden-source-root-reference": { + "severity": "error", + "roots": ["src/generated"], + "kinds": ["value", "type"] + }, + "preferred-type-syntax": { + "severity": "warning", + "dict": true + } + } + }, + "rewrite": { + "rules": { + "prefer-switch": { + "enabled": true, + "if": true, + "ternary": true + }, + "no-optional-some": { + "enabled": true + }, + "preferred-type-syntax": { + "enabled": true, + "dict": true + } + } + } +} 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/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..8c39204722a --- /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: Do not use Belt.Array helpers here. Prefer Stdlib/Array directly. +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..a3dffb05145 --- /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":"Do not use Belt.Array helpers here. Prefer Stdlib/Array directly.","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/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/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 new file mode 100644 index 00000000000..ea63f55b63a --- /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: Prefer Array.map directly. +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..ca23d9aff2d --- /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":"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 new file mode 100644 index 00000000000..8ae643c82f9 --- /dev/null +++ b/tests/tools_tests/src/expected/ForbiddenType.res.lint.expected @@ -0,0 +1,23 @@ +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: Avoid Js.Json.t here. +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..35c0ee76caa --- /dev/null +++ b/tests/tools_tests/src/expected/ForbiddenType.res.lint.json.expected @@ -0,0 +1 @@ +[{"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/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/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/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..6d5cc59a14f --- /dev/null +++ b/tests/tools_tests/src/expected/RewriteIfs.res.rewrite.diff.expected @@ -0,0 +1,47 @@ +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,21 +1,22 @@ + let flag = true + let other = false + +-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 = 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..d92a37ec030 --- /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,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/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..fdf29b1fcae --- /dev/null +++ b/tests/tools_tests/src/expected/RewriteOptionalSome.res.rewrite.diff.expected @@ -0,0 +1,34 @@ +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,15 +1,12 @@ + let flag = true + let useValue = (~value=?, ()) => value + +-let direct = useValue(~value=?Some(1), ()) ++let direct = useValue(~value=1, ()) + 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"), ()) +``` + +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..ddf4cd6333a --- /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,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/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/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/SingleUse.res.lint.expected b/tests/tools_tests/src/expected/SingleUse.res.lint.expected new file mode 100644 index 00000000000..c68bb195e11 --- /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: Inline this helper unless it is a meaningful reusable abstraction. +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..cb1236a36e9 --- /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":"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 new file mode 100644 index 00000000000..7c128fe49ec --- /dev/null +++ b/tests/tools_tests/src/expected/active-rules.expected @@ -0,0 +1,103 @@ +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: 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 new file mode 100644 index 00000000000..b9af89f9865 --- /dev/null +++ b/tests/tools_tests/src/expected/active-rules.json.expected @@ -0,0 +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":"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/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/expected/lint-root.expected b/tests/tools_tests/src/expected/lint-root.expected new file mode 100644 index 00000000000..b46f5f1adf9 --- /dev/null +++ b/tests/tools_tests/src/expected/lint-root.expected @@ -0,0 +1,331 @@ +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: Do not use Belt.Array helpers here. Prefer Stdlib/Array directly. +symbol: Belt_Array.forEach +snippet: +```text +> 1 | let run = () => Belt.Array.forEach([1, 2], x => ignore(x)) + | ^^^^^^^ +``` + +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 +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: Prefer Array.map directly. +symbol: Belt_Array.map +snippet: +```text + 1 | open Belt.Array +> 3 | let run = () => map([1, 2], x => x + 1) + | ^^^ +``` + +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: Avoid Js.Json.t here. +symbol: Js_json.t +snippet: +```text +> 1 | type json = Js.Json.t + | ^ +``` + +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 +range: 2:7-2:13 +message: Inline this helper unless it is a meaningful reusable abstraction. +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..83d1737bf0a --- /dev/null +++ b/tests/tools_tests/src/expected/lint-root.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"},{"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/expected/rewrite-root.diff.expected b/tests/tools_tests/src/expected/rewrite-root.diff.expected new file mode 100644 index 00000000000..8ccd2a82994 --- /dev/null +++ b/tests/tools_tests/src/expected/rewrite-root.diff.expected @@ -0,0 +1,127 @@ +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,21 +1,22 @@ + let flag = true + let other = false + +-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 = 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,15 +1,12 @@ + let flag = true + let useValue = (~value=?, ()) => value + +-let direct = useValue(~value=?Some(1), ()) ++let direct = useValue(~value=1, ()) + 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"), ()) +``` + +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 new file mode 100644 index 00000000000..0660bc1418f --- /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,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/src/expected/rewrite-root.expected b/tests/tools_tests/src/expected/rewrite-root.expected new file mode 100644 index 00000000000..cec82e11e7f --- /dev/null +++ b/tests/tools_tests/src/expected/rewrite-root.expected @@ -0,0 +1,25 @@ +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` + +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 new file mode 100644 index 00000000000..f1cd48030c1 --- /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`"}]},{"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/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/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/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/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/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/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/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/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/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/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/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..3ffcd688a08 --- /dev/null +++ b/tests/tools_tests/src/rewrite/RewriteIfs.res @@ -0,0 +1,21 @@ +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..eeac60acee2 --- /dev/null +++ b/tests/tools_tests/src/rewrite/RewriteOptionalSome.res @@ -0,0 +1,15 @@ +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/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/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 4e44f421708..057cf9810f1 100755 --- a/tests/tools_tests/test.sh +++ b/tests/tools_tests/test.sh @@ -33,6 +33,179 @@ 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" + ../../_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 check "$file" --json > "$json_output" || true + if [ "$RUNNER_OS" == "Windows" ]; then + perl -pi -e 's/\r\n/\n/g' -- "$json_output" + fi +done + +generated_file="src/generated/GeneratedConsumer.res" +generated_output="src/expected/$(basename $generated_file).lint.expected" +../../_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 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 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 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 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 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 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 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 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 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 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 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 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 support show String --kind module > /dev/null || exit 1 + +# Test find-references command +../../_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 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 support 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,resi}; do + tmp_file=".tmp-rewrite-tests/$(basename $file)" + cp "$file" "$tmp_file" + + output="src/expected/$(basename $file).rewrite.expected" + ../../_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 + + 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-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 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 run "$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,resi} .tmp-rewrite-tests/root/ +../../_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 + +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 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 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 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 + +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/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/ai_cli.ml b/tools/bin/ai_cli.ml new file mode 100644 index 00000000000..385914eff6e --- /dev/null +++ b/tools/bin/ai_cli.ml @@ -0,0 +1,393 @@ +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_check_help prog_name = + Printf.sprintf + {|%s + +Run AI-oriented lint checks on a file or directory + +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 check ./src/MyModule.res|} + banner prog_name 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 run [--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 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 = + Printf.sprintf + {|%s + +List lint and rewrite rules, whether they are currently active, and what they do + +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 support 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 support 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 support 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 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 support find-references String.localeCompare|} + banner prog_name prog_name prog_name + +let support_help prog_name = + Printf.sprintf + {|%s + +Run AI-oriented support commands + +Usage: %s support + +Commands: + +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 + +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 top_level_commands = + ["lint"; "rewrite"; "support"; "active-rules"; "show"; "find-references"] + +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 + (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 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))) + +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/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 130ce24ac74..511e704ce66 100644 --- a/tools/bin/main.ml +++ b/tools/bin/main.ml @@ -171,6 +171,9 @@ let main () = print_endline (Analysis.Protocol.stringifyResult r); exit 1) | _ -> logAndExit (Error extractCodeblocksHelp)) + | 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 diff --git a/tools/src/active_rules.ml b/tools/src/active_rules.ml new file mode 100644 index 00000000000..159f7993af7 --- /dev/null +++ b/tools/src/active_rules.ml @@ -0,0 +1,120 @@ +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)); + ("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)); + ("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; + ] + 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) + 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/find_references.ml b/tools/src/find_references.ml new file mode 100644 index 00000000000..b7334e9abad --- /dev/null +++ b/tools/src/find_references.ml @@ -0,0 +1,276 @@ +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/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..6d596d7a6a4 --- /dev/null +++ b/tools/src/lint_analysis.ml @@ -0,0 +1,799 @@ +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 + +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 + +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.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 + +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 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 + 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 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) + | 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 + | 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 = + Lint_support.SymbolPath.resolve_module_env ~package path + in + Hashtbl.add resolved_module_cache (path_key path) resolved; + resolved + in + 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.path in + match resolve_module_prefix module_prefix with + | None -> loop (prefix_length - 1) + | 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 + 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.path + in + let item = {item with path = resolved} in + Hashtbl.add resolved_cache key item; + item + 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 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 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 ast_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 -> + ast_findings := + value_alias_avoidance_findings ~path rule bindings @ !ast_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 -> + 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 + | None -> () + | Some finding -> ast_findings := finding :: !ast_findings)) + | Pstr_recmodule module_bindings -> ( + match alias_avoidance_rule with + | None -> () + | Some rule -> + ast_findings := + (module_bindings + |> List.filter_map (module_alias_avoidance_finding ~path rule) + ) + @ !ast_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 -> + ast_findings := + type_alias_avoidance_findings ~path rule decls @ !ast_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 -> ast_findings := finding :: !ast_findings)) + | Psig_recmodule module_declarations -> ( + match alias_avoidance_rule with + | None -> () + | Some rule -> + ast_findings := + (module_declarations + |> List.filter_map + (module_declaration_alias_avoidance_finding ~path rule)) + @ !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 + | 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 -> ast_findings := finding :: !ast_findings)) + | _ -> ()); + 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}, + List.rev !ast_findings ) +end + +module Typed = struct + 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 + | 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 + (resolved_symbol_of_declared ~module_name:env.file.moduleName + ~kind:ForbiddenReferenceValue) + | Tip.Type -> + Stamps.findType env.file.stamps stamp + |> Option.map + (resolved_symbol_of_declared ~module_name:env.file.moduleName + ~kind:ForbiddenReferenceType) + | Tip.Module -> + Stamps.findModule env.file.stamps stamp + |> 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 -> + { + 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 -> + { + 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 + (resolved_symbol_of_declared ~module_name:file.moduleName + ~kind:ForbiddenReferenceValue) + | Tip.Type -> + Stamps.findType file.stamps stamp + |> Option.map + (resolved_symbol_of_declared ~module_name:file.moduleName + ~kind:ForbiddenReferenceType) + | Tip.Module -> + Stamps.findModule file.stamps stamp + |> 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 -> + { + 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 -> + { + 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 + | 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) = + 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 forbidden_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 (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 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 + | _ -> false + + let single_use_function_findings ~config ~path ~local_function_bindings + (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) -> + 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:rule.severity + ~message:(effective_single_use_function_message rule) + ?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 : raw_finding) (right : raw_finding) = + 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 : 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 ) + 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 alias_avoidance_rule = + if config.alias_avoidance.enabled then Some config.alias_avoidance else None + in + 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 ( + findings := ast_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.forbidden_source_root_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 + |> 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 + 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..a4ae6fbd51f --- /dev/null +++ b/tools/src/lint_config.ml @@ -0,0 +1,436 @@ +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_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} + +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; + forbidden_source_root_reference = + default_forbidden_source_root_reference_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; + }; + } + +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_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 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) + | 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 ?config_dir json = + let lint_json = json |> Json.get "lint" in + let rules = + match Option.bind lint_json (Json.get "rules") with + | Some (Json.Object _) as rules -> 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 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 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_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) + 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 + ({ + 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 + | 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 + 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")) + (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.bind + (parse_singleton_rule ~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:"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 + { + 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 = + 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 -> + Result.bind + (Lint_support.Json.read_file path) + (parse_config_json ~config_dir:(Filename.dirname path)) diff --git a/tools/src/lint_output.ml b/tools/src/lint_output.ml new file mode 100644 index 00000000000..ab1106f49bd --- /dev/null +++ b/tools/src/lint_output.ml @@ -0,0 +1,63 @@ +open Analysis +open Lint_shared + +let snippet_of_raw ~source_cache (raw_finding : raw_finding) = + 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) = + [ + ("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 = Lint_support.Snippet.create_cache () 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..a10805c92bd --- /dev/null +++ b/tools/src/lint_shared.ml @@ -0,0 +1,534 @@ +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; + 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; + message: string option; + items: forbidden_reference_item list; +} + +type alias_avoidance_rule = { + enabled: bool; + severity: severity; + 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; + message: string option; + dict: bool; +} + +type prefer_switch_rule = { + enabled: bool; + rewrite_if: bool; + rewrite_ternary: bool; +} + +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; + forbidden_source_root_reference: forbidden_source_root_reference_rule; + preferred_type_syntax: preferred_type_syntax_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; + instance: int option; + 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; +} + +let severity_to_string = function + | SeverityError -> "error" + | SeverityWarning -> "warning" + +let forbidden_reference_kind_to_string = function + | ForbiddenReferenceModule -> "module" + | 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" + +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 = + path = ["Dict"; "t"] || path = ["Stdlib"; "Dict"; "t"] + +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 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 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"; + 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"; + 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 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; + forbidden_source_root_reference_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 = + 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 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 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 + +let rule_listings_of_config (config : config) = + 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 + 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 + [ + { + 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 @ forbidden_source_root_reference_listings + @ preferred_type_syntax_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)]; + }; + { + 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 + | "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..f4d46fe54b0 --- /dev/null +++ b/tools/src/lint_support.ml @@ -0,0 +1,194 @@ +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 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 + + 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/rewrite.ml b/tools/src/rewrite.ml new file mode 100644 index 00000000000..b4f67790c56 --- /dev/null +++ b/tools/src/rewrite.ml @@ -0,0 +1,685 @@ +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; + preferred_type_syntax: 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); + ("preferred-type-syntax", rule_counts.preferred_type_syntax); + ] + |> 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 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} -> + 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 + 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; 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; + } + 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/show.ml b/tools/src/show.ml new file mode 100644 index 00000000000..6242dd3b612 --- /dev/null +++ b/tools/src/show.ml @@ -0,0 +1,162 @@ +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 eb591aa9123..65e545137af 100644 --- a/tools/src/tools.ml +++ b/tools/src/tools.ml @@ -1,5 +1,6 @@ open Analysis +module ActiveRules = Active_rules module StringSet = Set.Make (String) type fieldDoc = { @@ -1294,3 +1295,7 @@ module ExtractCodeblocks = struct end module Migrate = Migrate +module Lint = Lint +module Rewrite = Rewrite +module Show = Show +module Find_references = Find_references 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