git.iko.soy/rowtype-yoga/purslint
on main help sign in
Initial commit: PureLint - a PureScript linter

A PureScript linter inspired by hlint, compiled to native Go binaries. Features: - 10 lint rules (FmapId, ConcatMap, MapFusion, UseTraverse, NotEqual, UseGuard, EtaReduce, EtaReduceDecl, RedundantBind, LetToWhere) - CLI tool for linting files - LSP server for IDE integration - Zed editor extension - Test suite with 53 tests ๐Ÿ’˜ Generated with Crush Assisted-by: Claude Opus 4.5 via Crush <crush@charm.land>

commitd69d827ab1e1b05e6985865007e1ae3840362175 authorMark <mark.eibes@gmail.com> dateSun 25 Jan 2026, 15:52 (8 months ago)
46 files changed+5415
.gitignore+28 README.md+139 bin/purelint-cli/spago.yaml+13 bin/purelint-cli/src/Main.purs+227 bin/purelint-cli/src/Node/FS/Sync.purs+26 bin/purelint-cli/src/Node/Process.purs+13 bin/purelint-lsp/spago.yaml+12 bin/purelint-lsp/src/LSP.purs+318 bin/purelint-lsp/src/LSP/Stdio.purs+21 bin/purelint-lsp/src/Main.purs+8 docs/IMPORT_CHECKING_PLAN.md+129 docs/TESTING_PLAN.md+173 examples/BadCode.purs+29 examples/TestFix.purs+29 go.mod+17 go.sum+8 lib/purelint/spago.yaml+15 lib/purelint/src/Purelint/Config.purs+41 lib/purelint/src/Purelint/Fix.purs+107 lib/purelint/src/Purelint/Imports.purs+140 lib/purelint/src/Purelint/Print.purs+28 lib/purelint/src/Purelint/Rule.purs+23 lib/purelint/src/Purelint/Rules/ConcatMap.purs+84 lib/purelint/src/Purelint/Rules/EtaReduce.purs+82 lib/purelint/src/Purelint/Rules/EtaReduceDecl.purs+102 lib/purelint/src/Purelint/Rules/FmapId.purs+105 lib/purelint/src/Purelint/Rules/LetToWhere.purs+53 lib/purelint/src/Purelint/Rules/MapFusion.purs+142 lib/purelint/src/Purelint/Rules/NotEqual.purs+82 lib/purelint/src/Purelint/Rules/RedundantBind.purs+76 lib/purelint/src/Purelint/Rules/UseGuard.purs+99 lib/purelint/src/Purelint/Rules/UseTraverse.purs+125 lib/purelint/src/Purelint/Runner.purs+45 lib/purelint/src/Purelint/Types.purs+92 purescript-native/ffi_loader.go+30 purescript-native/go.mod+12 purescript-native/go.sum+6 spago.lock+1363 spago.yaml+21 test/LintExamples.purs+140 test/Test/Main.purs+343 zed-extension/Cargo.lock+823 zed-extension/Cargo.toml+10 zed-extension/extension.toml+13 zed-extension/extension.wasm zed-extension/src/lib.rs+23
.gitignorenew file+28
@@ -0,0 +1,28 @@
1+# PureScript
2+bower_components/
3+node_modules/
4+.pulp-cache/
5+output/
6+output-es/
7+generated-docs/
8+.psc-package/
9+.psc*
10+.purs*
11+.psa*
12+.spago
13+
14+# Go build artifacts
15+main_cli.go
16+main_lsp.go
17+test_main.go
18+debug_main.go
19+
20+# Binaries (root level only)
21+/purelint
22+/purelint-lsp
23+/test-runner
24+/debug-runner
25+/Main
26+
27+# Zed extension build
28+zed-extension/target/
README.mdnew file+139
@@ -0,0 +1,139 @@
1+# PureLint
2+
3+A PureScript linter inspired by [hlint](https://github.com/ndmitchell/hlint), providing suggestions for cleaner, more idiomatic PureScript code.
4+
5+## Features
6+
7+PureLint includes 10 lint rules:
8+
9+| Rule | Description | Example |
10+|------|-------------|---------|
11+| `FmapId` | `map identity x` โ†’ `x` | Redundant identity mapping |
12+| `ConcatMap` | `join <<< map f` โ†’ `f =<< _` | Use bind instead of join+map |
13+| `MapFusion` | `map f <<< map g` โ†’ `map (f <<< g)` | Fuse consecutive maps |
14+| `UseTraverse` | `sequence <<< map f` โ†’ `traverse f` | Use traverse |
15+| `NotEqual` | `not (x == y)` โ†’ `x /= y` | Use not-equal operator |
16+| `UseGuard` | `if c then Just x else Nothing` โ†’ `guard` | Use guard from Control.MonadZero |
17+| `EtaReduce` | `\x -> f x` โ†’ `f` | Eta reduce lambdas |
18+| `EtaReduceDecl` | `f x = g x` โ†’ `f = g` | Eta reduce declarations |
19+| `RedundantBind` | `x >>= pure` โ†’ `x` | Remove redundant bind |
20+| `LetToWhere` | `let x = y in z` โ†’ `z where x = y` | Prefer where clauses |
21+
22+## Installation
23+
24+### Prerequisites
25+
26+- [PureScript](https://www.purescript.org/) compiler (`purs`)
27+- [Spago](https://github.com/purescript/spago) package manager
28+- [Go](https://golang.org/) 1.19+
29+- [psgo](https://github.com/puregocore/psgo) - PureScript to Go compiler
30+
31+### Building
32+
33+```bash
34+# Install dependencies
35+spago install
36+
37+# Build CLI
38+purs compile --codegen corefn 'lib/**/*.purs' 'bin/purelint-cli/**/*.purs' '.spago/**/*.purs'
39+psgo output/*/corefn.json
40+cp output/Main/Main.go main_cli.go
41+go build -o purelint main_cli.go
42+
43+# Build LSP
44+purs compile --codegen corefn 'lib/**/*.purs' 'bin/purelint-lsp/**/*.purs' '.spago/**/*.purs'
45+psgo output/*/corefn.json
46+cp output/Main/Main.go main_lsp.go
47+go build -o purelint-lsp main_lsp.go
48+
49+# Build and run tests
50+purs compile --codegen corefn 'lib/**/*.purs' 'test/**/*.purs' '.spago/**/*.purs'
51+psgo output/*/corefn.json
52+cp output/Test.Main/Test_Main.go test_main.go
53+go build -o test-runner test_main.go
54+./test-runner
55+```
56+
57+## Usage
58+
59+### CLI
60+
61+```bash
62+# Lint a single file
63+./purelint src/Main.purs
64+
65+# Lint multiple files
66+./purelint src/*.purs
67+
68+# Lint with config file (optional)
69+./purelint --config purelint.json src/*.purs
70+```
71+
72+### LSP (Language Server Protocol)
73+
74+The LSP binary (`purelint-lsp`) provides IDE integration. It communicates via stdio using the standard LSP protocol.
75+
76+#### Zed Editor
77+
78+A Zed extension is available in the `zed-extension/` directory. To install:
79+
80+1. Open Zed
81+2. Go to Extensions (Cmd+Shift+X)
82+3. Click "Install Dev Extension"
83+4. Select the `zed-extension/` directory
84+
85+The extension will automatically use the `purelint-lsp` binary.
86+
87+## Configuration
88+
89+Create a `purelint.json` file in your project root:
90+
91+```json
92+{
93+ "disabledRules": ["LetToWhere", "EtaReduce"]
94+}
95+```
96+
97+## Architecture
98+
99+PureLint is written in PureScript and compiled to Go using `psgo` (purescript-native). This allows:
100+
101+- Full access to the PureScript CST parser (`purescript-language-cst-parser`)
102+- Native binary performance
103+- Easy distribution without runtime dependencies
104+
105+### Project Structure
106+
107+```
108+purelint/
109+โ”œโ”€โ”€ lib/purelint/src/ # Core library
110+โ”‚ โ””โ”€โ”€ Purelint/
111+โ”‚ โ”œโ”€โ”€ Rules/ # Lint rules
112+โ”‚ โ”œโ”€โ”€ Runner.purs # Rule runner
113+โ”‚ โ”œโ”€โ”€ Types.purs # Core types
114+โ”‚ โ””โ”€โ”€ Imports.purs # Import checking
115+โ”œโ”€โ”€ bin/
116+โ”‚ โ”œโ”€โ”€ purelint-cli/ # CLI application
117+โ”‚ โ””โ”€โ”€ purelint-lsp/ # LSP server
118+โ”œโ”€โ”€ test/ # Test suite
119+โ””โ”€โ”€ zed-extension/ # Zed editor extension
120+```
121+
122+## Development
123+
124+### Adding a New Rule
125+
126+1. Create a new file in `lib/purelint/src/Purelint/Rules/`
127+2. Implement the rule using the CST traversal API
128+3. Export the rule from `Purelint.Rules`
129+4. Add tests in `test/Test/Main.purs`
130+5. Rebuild and test
131+
132+### Key Dependencies
133+
134+- `purescript-language-cst-parser` - PureScript CST parser
135+- `purescript-lsp` - LSP protocol implementation (Go FFI)
136+
137+## License
138+
139+MIT
bin/purelint-cli/spago.yamlnew file+13
@@ -0,0 +1,13 @@
1+package:
2+ name: purelint-cli
3+ dependencies:
4+ - arrays
5+ - console
6+ - effect
7+ - either
8+ - foldable-traversable
9+ - maybe
10+ - newtype
11+ - prelude
12+ - purelint
13+ - strings
bin/purelint-cli/src/Main.pursnew file+227
@@ -0,0 +1,227 @@
1+module Main where
2+
3+import Prelude
4+
5+import Data.Array as Array
6+import Data.Either (Either(..))
7+import Data.Foldable (for_)
8+import Data.Maybe (Maybe(..))
9+import Data.Newtype (unwrap)
10+import Data.String.CodeUnits as String
11+import Effect (Effect)
12+import Effect.Console (log)
13+import Node.FS.Sync as FS
14+import Node.Process as Process
15+import Purelint.Config (defaultConfig, filterRules, parseConfig)
16+import Purelint.Fix (applyAllFixes)
17+import Purelint.Rule (Rule)
18+import Purelint.Rules.ConcatMap (concatMapRule)
19+import Purelint.Rules.EtaReduce (etaReduceRule)
20+import Purelint.Rules.EtaReduceDecl (etaReduceDeclRule)
21+import Purelint.Rules.FmapId (fmapIdRule)
22+import Purelint.Rules.LetToWhere (letToWhereRule)
23+import Purelint.Rules.MapFusion (mapFusionRule)
24+import Purelint.Rules.NotEqual (notEqualRule)
25+import Purelint.Rules.RedundantBind (redundantBindRule)
26+import Purelint.Rules.UseGuard (useGuardRule)
27+import Purelint.Rules.UseTraverse (useTraverseRule)
28+import Purelint.Runner (runRules)
29+import Purelint.Types (LintResult(..), LintWarning(..), Severity(..), SourceCode(..), Suggestion(..))
30+
31+-- | All available rules
32+allRules :: Array Rule
33+allRules =
34+ [ useTraverseRule
35+ , mapFusionRule
36+ , fmapIdRule
37+ , notEqualRule
38+ , concatMapRule
39+ , useGuardRule
40+ , etaReduceRule
41+ , etaReduceDeclRule
42+ , redundantBindRule
43+ , letToWhereRule
44+ ]
45+
46+-- | Load config from .purelintignore file
47+loadConfig :: Effect _
48+loadConfig = do
49+ hasConfig <- FS.exists ".purelintignore"
50+ if hasConfig then do
51+ content <- FS.readTextFile ".purelintignore"
52+ pure $ parseConfig content
53+ else
54+ pure defaultConfig
55+
56+-- | Format a source range for display
57+formatRange :: { start :: { line :: Int, column :: Int }, end :: { line :: Int, column :: Int } } -> String
58+formatRange r = show (r.start.line + 1) <> ":" <> show (r.start.column + 1) <> "-" <> show (r.end.line + 1) <> ":" <> show (r.end.column + 1)
59+
60+-- | Format severity for display
61+formatSeverity :: Severity -> String
62+formatSeverity Hint = "[hint]"
63+formatSeverity Warning = "[warning]"
64+formatSeverity Error = "[error]"
65+
66+-- | Format a single warning for display
67+formatWarning :: String -> LintWarning -> String
68+formatWarning filePath (LintWarning w) =
69+ filePath <> ":" <> formatRange w.range <> " " <> formatSeverity w.severity <> " " <> unwrap w.ruleId <> ": " <> unwrap w.message <>
70+ case w.suggestion of
71+ Nothing -> ""
72+ Just (Suggestion s) -> "\n Replace with: " <> unwrap s.replacement
73+
74+-- | Lint a single file with config
75+lintFileWithConfig :: Array Rule -> String -> Effect Int
76+lintFileWithConfig rules filePath = do
77+ content <- FS.readTextFile filePath
78+ case runRules rules (SourceCode content) of
79+ Left err -> do
80+ log $ filePath <> ": " <> err
81+ pure 0
82+ Right (LintResult result) -> do
83+ for_ result.warnings \w -> do
84+ log $ formatWarning filePath w
85+ pure (Array.length result.warnings)
86+
87+-- | Fix a single file
88+fixFile :: Array Rule -> String -> Effect Int
89+fixFile rules filePath = do
90+ content <- FS.readTextFile filePath
91+ case runRules rules (SourceCode content) of
92+ Left err -> do
93+ log $ filePath <> ": " <> err
94+ pure 0
95+ Right result@(LintResult r) -> do
96+ let fixedContent = applyAllFixes content result
97+ when (fixedContent /= content) do
98+ FS.writeTextFile filePath fixedContent
99+ log $ filePath <> ": fixed " <> show (Array.length r.warnings) <> " issue(s)"
100+ pure (Array.length r.warnings)
101+
102+-- | Recursively find all .purs files in a directory
103+findPursFiles :: String -> Effect (Array String)
104+findPursFiles dir = do
105+ entries <- FS.readdir dir
106+ files <- Array.foldM (collectFiles dir) [] entries
107+ pure files
108+ where
109+ collectFiles :: String -> Array String -> String -> Effect (Array String)
110+ collectFiles parentDir acc entry = do
111+ let path = parentDir <> "/" <> entry
112+ stats <- FS.stat path
113+ if stats.isDirectory then do
114+ subFiles <- findPursFiles path
115+ pure (acc <> subFiles)
116+ else if String.takeRight 5 entry == ".purs" then
117+ pure (Array.snoc acc path)
118+ else
119+ pure acc
120+
121+-- | Lint a file or directory with config
122+lintPathWithConfig :: Array Rule -> String -> Effect Int
123+lintPathWithConfig rules path = do
124+ stats <- FS.stat path
125+ if stats.isDirectory then do
126+ files <- findPursFiles path
127+ counts <- Array.foldM (\acc file -> do
128+ count <- lintFileWithConfig rules file
129+ pure (acc + count)) 0 files
130+ pure counts
131+ else
132+ lintFileWithConfig rules path
133+
134+-- | Fix a file or directory
135+fixPath :: Array Rule -> String -> Effect Int
136+fixPath rules path = do
137+ stats <- FS.stat path
138+ if stats.isDirectory then do
139+ files <- findPursFiles path
140+ counts <- Array.foldM (\acc file -> do
141+ count <- fixFile rules file
142+ pure (acc + count)) 0 files
143+ pure counts
144+ else
145+ fixFile rules path
146+
147+-- | Show usage
148+showUsage :: Effect Unit
149+showUsage = do
150+ log "purelint - a linter for PureScript"
151+ log ""
152+ log "Usage: purelint [OPTIONS] [FILES/DIRECTORIES...]"
153+ log ""
154+ log "Options:"
155+ log " --help Show this help"
156+ log " --fix Auto-fix issues (where possible)"
157+ log " --init Create a sample .purelintignore"
158+ log " --list List all available rules"
159+ log ""
160+ log "Configuration:"
161+ log " Create .purelintignore in your project root to disable rules."
162+ log " Add one rule name per line (lines starting with # are comments)."
163+ log ""
164+ log "Examples:"
165+ log " purelint src/ Lint all .purs files in src/"
166+ log " purelint --fix src/ Auto-fix issues in src/"
167+ log " purelint src/Main.purs Lint a single file"
168+
169+-- | List all available rules
170+listRules :: Effect Unit
171+listRules = do
172+ log "Available rules:"
173+ log ""
174+ for_ allRules \rule -> do
175+ log $ " " <> unwrap rule.ruleId
176+
177+-- | Create sample config file
178+initConfig :: Effect Unit
179+initConfig = do
180+ hasConfig <- FS.exists ".purelintignore"
181+ if hasConfig then
182+ log ".purelintignore already exists"
183+ else do
184+ let sampleConfig = """# purelint ignore file
185+# Add rule names (one per line) to disable them
186+# Example:
187+# EtaReduce
188+# MapFusion
189+"""
190+ FS.writeTextFile ".purelintignore" sampleConfig
191+ log "Created .purelintignore"
192+
193+main :: Effect Unit
194+main = do
195+ args <- Process.argv
196+ -- args[0] is the executable, args[1..] are the actual arguments
197+ let userArgs = Array.drop 1 args
198+ case userArgs of
199+ [] -> showUsage
200+ ["--help"] -> showUsage
201+ ["-h"] -> showUsage
202+ ["--list"] -> listRules
203+ ["--init"] -> initConfig
204+ _ -> do
205+ -- Check for --fix flag
206+ let hasFix = Array.elem "--fix" userArgs
207+ let paths = Array.filter (\a -> a /= "--fix") userArgs
208+
209+ -- Load config and filter rules
210+ config <- loadConfig
211+ let enabledRules = filterRules allRules config
212+
213+ if hasFix then do
214+ -- Fix mode
215+ _ <- Array.foldM (\acc path -> do
216+ count <- fixPath enabledRules path
217+ pure (acc + count)) 0 paths
218+ pure unit
219+ else do
220+ -- Lint mode
221+ totalWarnings <- Array.foldM (\acc path -> do
222+ count <- lintPathWithConfig enabledRules path
223+ pure (acc + count)) 0 paths
224+ when (totalWarnings > 0) do
225+ log ""
226+ log $ "Total: " <> show totalWarnings <> " warning" <> if totalWarnings == 1 then "" else "s"
227+ Process.exit 1
bin/purelint-cli/src/Node/FS/Sync.pursnew file+26
@@ -0,0 +1,26 @@
1+module Node.FS.Sync where
2+
3+import Prelude
4+import Effect (Effect)
5+
6+-- | Read file contents as a string
7+foreign import readTextFile :: String -> Effect String
8+
9+-- | Write file contents as a string
10+foreign import writeTextFile :: String -> String -> Effect Unit
11+
12+-- | Check if file exists
13+foreign import exists :: String -> Effect Boolean
14+
15+-- | Read directory contents
16+foreign import readdir :: String -> Effect (Array String)
17+
18+-- | Get file stats
19+type Stats =
20+ { isFile :: Boolean
21+ , isDirectory :: Boolean
22+ , isSymbolicLink :: Boolean
23+ , size :: Int
24+ }
25+
26+foreign import stat :: String -> Effect Stats
bin/purelint-cli/src/Node/Process.pursnew file+13
@@ -0,0 +1,13 @@
1+module Node.Process where
2+
3+import Prelude
4+import Effect (Effect)
5+
6+-- | Get command line arguments
7+foreign import argv :: Effect (Array String)
8+
9+-- | Get current working directory
10+foreign import cwd :: Effect String
11+
12+-- | Exit with status code
13+foreign import exit :: Int -> Effect Unit
bin/purelint-lsp/spago.yamlnew file+12
@@ -0,0 +1,12 @@
1+package:
2+ name: purelint-lsp
3+ dependencies:
4+ - arrays
5+ - effect
6+ - either
7+ - foreign
8+ - maybe
9+ - newtype
10+ - prelude
11+ - purelint
12+ - unsafe-coerce
bin/purelint-lsp/src/LSP.pursnew file+318
@@ -0,0 +1,318 @@
1+module Purelint.LSP where
2+
3+import Prelude
4+
5+import Data.Array as Array
6+import Data.Either (Either(..))
7+import Data.Maybe (Maybe(..), fromMaybe)
8+import Data.Newtype (unwrap)
9+import Effect (Effect)
10+import Foreign (Foreign)
11+import LSP.Stdio as Stdio
12+import Purelint.Config (defaultConfig, filterRules)
13+import Purelint.Rule (Rule)
14+import Purelint.Rules.ConcatMap (concatMapRule)
15+import Purelint.Rules.EtaReduce (etaReduceRule)
16+import Purelint.Rules.EtaReduceDecl (etaReduceDeclRule)
17+import Purelint.Rules.FmapId (fmapIdRule)
18+import Purelint.Rules.LetToWhere (letToWhereRule)
19+import Purelint.Rules.MapFusion (mapFusionRule)
20+import Purelint.Rules.NotEqual (notEqualRule)
21+import Purelint.Rules.RedundantBind (redundantBindRule)
22+import Purelint.Rules.UseGuard (useGuardRule)
23+import Purelint.Rules.UseTraverse (useTraverseRule)
24+import Purelint.Runner (runRules)
25+import Purelint.Types (LintResult(..), LintWarning(..), Severity(..), SourceCode(..), Suggestion(..))
26+import Unsafe.Coerce (unsafeCoerce)
27+
28+-- | All available rules
29+allRules :: Array Rule
30+allRules =
31+ [ useTraverseRule
32+ , mapFusionRule
33+ , fmapIdRule
34+ , notEqualRule
35+ , concatMapRule
36+ , useGuardRule
37+ , etaReduceRule
38+ , etaReduceDeclRule
39+ , redundantBindRule
40+ , letToWhereRule
41+ ]
42+
43+-- | Get enabled rules
44+enabledRules :: Array Rule
45+enabledRules = filterRules allRules defaultConfig
46+
47+-- | Convert lint warning to LSP diagnostic (with fix data embedded)
48+warningToDiagnostic :: LintWarning -> Foreign
49+warningToDiagnostic (LintWarning w) =
50+ let
51+ range =
52+ { start: { line: w.range.start.line, character: w.range.start.column }
53+ , end: { line: w.range.end.line, character: w.range.end.column }
54+ }
55+ base =
56+ { range: range
57+ , severity: severityToLSP w.severity
58+ , source: "purelint"
59+ , message: unwrap w.ruleId <> ": " <> unwrap w.message
60+ }
61+ in case w.suggestion of
62+ Nothing -> unsafeCoerce base
63+ Just (Suggestion sug) -> unsafeCoerce
64+ { range: range
65+ , severity: severityToLSP w.severity
66+ , source: "purelint"
67+ , message: unwrap w.ruleId <> ": " <> unwrap w.message
68+ , data:
69+ { hasFix: true
70+ , replacement: unwrap sug.replacement
71+ , description: unwrap sug.description
72+ }
73+ }
74+
75+severityToLSP :: Severity -> Int
76+severityToLSP Error = 1
77+severityToLSP Warning = 2
78+severityToLSP Hint = 4
79+
80+-- | Lint a document and return diagnostics
81+lintDocument :: String -> Array Foreign
82+lintDocument content =
83+ case runRules enabledRules (SourceCode content) of
84+ Left _ -> []
85+ Right (LintResult result) -> map warningToDiagnostic result.warnings
86+
87+-- | Create a publishDiagnostics notification
88+mkPublishDiagnostics :: String -> Array Foreign -> String
89+mkPublishDiagnostics uri diagnostics =
90+ Stdio.stringifyJSON $ unsafeCoerce
91+ { jsonrpc: "2.0"
92+ , method: "textDocument/publishDiagnostics"
93+ , params:
94+ { uri: uri
95+ , diagnostics: diagnostics
96+ }
97+ }
98+
99+-- | Create an initialize response
100+mkInitializeResponse :: Int -> String
101+mkInitializeResponse reqId =
102+ Stdio.stringifyJSON $ unsafeCoerce
103+ { jsonrpc: "2.0"
104+ , id: reqId
105+ , result:
106+ { capabilities:
107+ { textDocumentSync: 1 -- Full sync
108+ , codeActionProvider: true
109+ }
110+ , serverInfo:
111+ { name: "purelint-lsp"
112+ , version: "0.1.0"
113+ }
114+ }
115+ }
116+
117+-- | Create a simple response (null result)
118+mkResponse :: Int -> String
119+mkResponse reqId =
120+ Stdio.stringifyJSON $ unsafeCoerce
121+ { jsonrpc: "2.0"
122+ , id: reqId
123+ , result: mkNull unit
124+ }
125+
126+-- | Create code action response
127+mkCodeActionResponse :: Int -> Array Foreign -> String
128+mkCodeActionResponse reqId actions =
129+ Stdio.stringifyJSON $ unsafeCoerce
130+ { jsonrpc: "2.0"
131+ , id: reqId
132+ , result: actions
133+ }
134+
135+foreign import mkNull :: forall a. a -> Foreign
136+
137+-- | Helper to get a string field from Foreign
138+getField :: String -> Foreign -> Maybe String
139+getField key obj =
140+ if hasKey key obj
141+ then Just (unsafeCoerce (getKey key obj))
142+ else Nothing
143+
144+getFieldInt :: String -> Foreign -> Maybe Int
145+getFieldInt key obj =
146+ if hasKey key obj
147+ then Just (unsafeCoerce (getKey key obj))
148+ else Nothing
149+
150+getFieldObj :: String -> Foreign -> Maybe Foreign
151+getFieldObj key obj =
152+ if hasKey key obj
153+ then Just (getKey key obj)
154+ else Nothing
155+
156+getFieldArr :: String -> Foreign -> Maybe (Array Foreign)
157+getFieldArr key obj =
158+ if hasKey key obj
159+ then Just (unsafeCoerce (getKey key obj))
160+ else Nothing
161+
162+foreign import hasKey :: String -> Foreign -> Boolean
163+foreign import getKey :: String -> Foreign -> Foreign
164+
165+-- | Handle a single message
166+handleMessage :: Foreign -> Effect Unit
167+handleMessage msg = do
168+ let method = fromMaybe "" (getField "method" msg)
169+ let reqId = fromMaybe 0 (getFieldInt "id" msg)
170+
171+ Stdio.logMessage $ "Method: " <> method
172+
173+ if method == "initialize" then do
174+ Stdio.logMessage "Handling initialize"
175+ Stdio.writeMessage $ mkInitializeResponse reqId
176+ else if method == "initialized" then do
177+ Stdio.logMessage "Client initialized"
178+ else if method == "textDocument/didOpen" then do
179+ handleDidOpen msg
180+ else if method == "textDocument/didChange" then do
181+ handleDidChange msg
182+ else if method == "textDocument/didClose" then do
183+ handleDidClose msg
184+ else if method == "textDocument/codeAction" then do
185+ handleCodeAction msg reqId
186+ else if method == "shutdown" then do
187+ Stdio.logMessage "Shutdown"
188+ Stdio.writeMessage $ mkResponse reqId
189+ else if method == "exit" then do
190+ Stdio.logMessage "Exit"
191+ else do
192+ Stdio.logMessage $ "Unknown method: " <> method
193+
194+handleDidOpen :: Foreign -> Effect Unit
195+handleDidOpen msg = do
196+ Stdio.logMessage "Document opened"
197+ case getFieldObj "params" msg of
198+ Nothing -> pure unit
199+ Just params ->
200+ case getFieldObj "textDocument" params of
201+ Nothing -> pure unit
202+ Just textDoc -> do
203+ let uri = fromMaybe "" (getField "uri" textDoc)
204+ let text = fromMaybe "" (getField "text" textDoc)
205+ let diagnostics = lintDocument text
206+ Stdio.writeMessage $ mkPublishDiagnostics uri diagnostics
207+
208+handleDidChange :: Foreign -> Effect Unit
209+handleDidChange msg = do
210+ Stdio.logMessage "Document changed"
211+ case getFieldObj "params" msg of
212+ Nothing -> pure unit
213+ Just params -> do
214+ let uri = fromMaybe "" $ do
215+ textDoc <- getFieldObj "textDocument" params
216+ getField "uri" textDoc
217+ case getFieldArr "contentChanges" params of
218+ Nothing -> pure unit
219+ Just changes ->
220+ case Array.head changes of
221+ Nothing -> pure unit
222+ Just change -> do
223+ let text = fromMaybe "" (getField "text" change)
224+ let diagnostics = lintDocument text
225+ Stdio.writeMessage $ mkPublishDiagnostics uri diagnostics
226+
227+handleDidClose :: Foreign -> Effect Unit
228+handleDidClose msg = do
229+ Stdio.logMessage "Document closed"
230+ case getFieldObj "params" msg of
231+ Nothing -> pure unit
232+ Just params ->
233+ case getFieldObj "textDocument" params of
234+ Nothing -> pure unit
235+ Just textDoc -> do
236+ let uri = fromMaybe "" (getField "uri" textDoc)
237+ Stdio.writeMessage $ mkPublishDiagnostics uri []
238+
239+handleCodeAction :: Foreign -> Int -> Effect Unit
240+handleCodeAction msg reqId = do
241+ Stdio.logMessage "Code action requested"
242+ case getFieldObj "params" msg of
243+ Nothing -> Stdio.writeMessage $ mkCodeActionResponse reqId []
244+ Just params -> do
245+ -- Get the document URI
246+ let uri = fromMaybe "" $ do
247+ textDoc <- getFieldObj "textDocument" params
248+ getField "uri" textDoc
249+
250+ -- Get diagnostics from the request context
251+ let diagnostics = fromMaybe [] $ do
252+ context <- getFieldObj "context" params
253+ getFieldArr "diagnostics" context
254+
255+ -- Convert diagnostics to code actions
256+ let actions = Array.catMaybes $ map (diagnosticToCodeAction uri) diagnostics
257+
258+ Stdio.logMessage $ "Returning " <> show (Array.length actions) <> " code actions"
259+ Stdio.writeMessage $ mkCodeActionResponse reqId actions
260+
261+-- | Convert a diagnostic with fix data to a code action
262+diagnosticToCodeAction :: String -> Foreign -> Maybe Foreign
263+diagnosticToCodeAction uri diag = do
264+ dataField <- getFieldObj "data" diag
265+ if hasKey "replacement" dataField && hasKey "description" dataField
266+ then do
267+ replacement <- getField "replacement" dataField
268+ description <- getField "description" dataField
269+ range <- getFieldObj "range" diag
270+ pure $ mkCodeAction description uri range replacement diag
271+ else Nothing
272+
273+-- | Create a code action with a workspace edit
274+mkCodeAction :: String -> String -> Foreign -> String -> Foreign -> Foreign
275+mkCodeAction title uri range newText diagnostic =
276+ unsafeCoerce
277+ { title: title
278+ , kind: "quickfix"
279+ , diagnostics: [diagnostic]
280+ , isPreferred: true
281+ , edit:
282+ { documentChanges:
283+ [ { textDocument:
284+ { uri: uri
285+ , version: mkNull unit
286+ }
287+ , edits: [{ range: range, newText: newText }]
288+ }
289+ ]
290+ }
291+ }
292+
293+-- | Main loop
294+mainLoop :: Effect Unit
295+mainLoop = do
296+ msgStr <- Stdio.readMessage
297+ case msgStr of
298+ Nothing -> do
299+ Stdio.logMessage "No message, exiting"
300+ Just str -> do
301+ Stdio.logMessage $ "Got message: " <> str
302+ case Stdio.parseJSON str of
303+ Nothing -> do
304+ Stdio.logMessage "Failed to parse message"
305+ mainLoop
306+ Just msg -> do
307+ handleMessage msg
308+ -- Check if it was exit
309+ let method = fromMaybe "" (getField "method" msg)
310+ if method == "exit"
311+ then pure unit
312+ else mainLoop
313+
314+main :: Effect Unit
315+main = do
316+ Stdio.logMessage "purelint-lsp starting..."
317+ mainLoop
318+ Stdio.logMessage "purelint-lsp exiting"
bin/purelint-lsp/src/LSP/Stdio.pursnew file+21
@@ -0,0 +1,21 @@
1+module LSP.Stdio where
2+
3+import Prelude
4+import Data.Maybe (Maybe)
5+import Effect (Effect)
6+import Foreign (Foreign)
7+
8+-- | Read a JSON-RPC message from stdin
9+foreign import readMessage :: Effect (Maybe String)
10+
11+-- | Write a JSON-RPC message to stdout
12+foreign import writeMessage :: String -> Effect Unit
13+
14+-- | Log a message to stderr (for debugging)
15+foreign import logMessage :: String -> Effect Unit
16+
17+-- | Parse JSON string to Foreign
18+foreign import parseJSON :: String -> Maybe Foreign
19+
20+-- | Stringify Foreign to JSON
21+foreign import stringifyJSON :: Foreign -> String
bin/purelint-lsp/src/Main.pursnew file+8
@@ -0,0 +1,8 @@
1+module Main where
2+
3+import Prelude
4+import Effect (Effect)
5+import Purelint.LSP as LSP
6+
7+main :: Effect Unit
8+main = LSP.main
docs/IMPORT_CHECKING_PLAN.mdnew file+129
@@ -0,0 +1,129 @@
1+# Import Checking Plan
2+
3+## Goal
4+
5+Make lint rules safer by verifying that identifiers like `map`, `identity`, `<$>` actually refer to the standard Prelude functions, not user-defined functions with the same name.
6+
7+## Current State
8+
9+- `Purelint.Imports` module exists with:
10+ - `getImportInfo :: Module Void -> ImportInfo` - extracts import info from a module
11+ - `hasValue :: ImportInfo -> String -> Boolean` - check if a value is imported
12+ - `hasOp :: ImportInfo -> String -> Boolean` - check if an operator is imported
13+ - Lists of "Prelude-like" modules (Prelude, Data.Functor, Data.Traversable, etc.)
14+ - Lists of standard values/operators we care about
15+
16+## Implementation Plan
17+
18+### Step 1: Update Rule type to receive ImportInfo
19+
20+```purescript
21+-- In Purelint/Rule.purs
22+type RuleContext =
23+ { imports :: ImportInfo
24+ }
25+
26+type Rule =
27+ { ruleId :: RuleId
28+ , run :: RuleContext -> Module Void -> Array LintWarning
29+ }
30+
31+mkRule :: RuleId -> (RuleContext -> Module Void -> Array LintWarning) -> Rule
32+```
33+
34+### Step 2: Update Runner to pass context
35+
36+```purescript
37+-- In Purelint/Runner.purs
38+runRules :: Array Rule -> SourceCode -> Either String LintResult
39+runRules rules (SourceCode source) = do
40+ mod <- parseModule source
41+ let imports = getImportInfo mod
42+ let ctx = { imports }
43+ let warnings = rules >>= \rule -> rule.run ctx mod
44+ pure $ LintResult { warnings, moduleName: getModuleName mod }
45+```
46+
47+### Step 3: Update each rule to check imports
48+
49+Example for FmapId:
50+
51+```purescript
52+checkExpr ctx expr = case expr of
53+ ExprApp fnExpr args ->
54+ case fnExpr of
55+ ExprIdent qn | isMapLike qn && hasValue ctx.imports "map" ->
56+ -- ... existing logic
57+```
58+
59+For operators:
60+
61+```purescript
62+ ExprOp lhs ops | isIdentity lhs && hasOp ctx.imports "<$>" ->
63+ -- ... existing logic
64+```
65+
66+### Step 4: Handle qualified imports (future)
67+
68+Track qualified imports like `import Data.Functor as F` and check for `F.map`.
69+
70+```purescript
71+type ImportInfo =
72+ { preludeValues :: Set String
73+ , preludeOps :: Set String
74+ , hasPrelude :: Boolean
75+ , qualifiedModules :: Map String String -- alias -> module name
76+ }
77+```
78+
79+Then in rules, also check `QualifiedName { module: Just alias }` against the map.
80+
81+## Rules to Update
82+
83+All rules that check for specific identifiers:
84+
85+| Rule | Values to check | Operators to check |
86+|------|-----------------|-------------------|
87+| FmapId | `map`, `fmap`, `identity`, `id` | `<$>`, `<#>` |
88+| MapFusion | `map`, `fmap` | `<$>`, `<#>` |
89+| UseTraverse | `map`, `fmap`, `sequenceA` | `<$>` |
90+| ConcatMap | `concat`, `join`, `map`, `fmap` | |
91+| NotEqual | `not` | `==`, `/=` |
92+| UseGuard | `pure`, `when`, `unless` | |
93+| EtaReduce | (any function) | |
94+| EtaReduceDecl | (any function) | |
95+| RedundantBind | `pure` | `>>=` |
96+
97+## Files to Modify
98+
99+1. `lib/purelint/src/Purelint/Rule.purs` - Add RuleContext
100+2. `lib/purelint/src/Purelint/Runner.purs` - Pass context to rules
101+3. `lib/purelint/src/Purelint/Rules/*.purs` - All 9 rule files
102+4. `bin/purelint-cli/src/Main.purs` - No changes needed (uses Runner)
103+5. `bin/purelint-lsp/src/LSP.purs` - No changes needed (uses Runner)
104+
105+## Testing
106+
107+After implementation, test with:
108+
109+```purescript
110+module Test where
111+
112+-- Should NOT trigger (no Prelude import)
113+x = map identity [1,2,3]
114+
115+-- Define our own map
116+map :: forall a b. (a -> b) -> Array a -> Array b
117+map f xs = xs
118+```
119+
120+vs:
121+
122+```purescript
123+module Test where
124+
125+import Prelude
126+
127+-- SHOULD trigger (Prelude imported)
128+x = map identity [1,2,3]
129+```
docs/TESTING_PLAN.mdnew file+173
@@ -0,0 +1,173 @@
1+# Testing Plan
2+
3+## Goal
4+
5+Create a comprehensive test suite that verifies:
6+1. Each rule detects the patterns it should
7+2. Each rule does NOT fire on code it shouldn't
8+3. Auto-fix produces correct output
9+4. Import checking works (rules only fire when relevant imports exist)
10+
11+## Test Structure
12+
13+```
14+test/
15+ Test/
16+ Main.purs -- Test runner
17+ Rules/
18+ FmapIdSpec.purs
19+ MapFusionSpec.purs
20+ UseTraverseSpec.purs
21+ ConcatMapSpec.purs
22+ NotEqualSpec.purs
23+ UseGuardSpec.purs
24+ EtaReduceSpec.purs
25+ EtaReduceDeclSpec.purs
26+ RedundantBindSpec.purs
27+ LetToWhereSpec.purs
28+ ImportsSpec.purs -- Test import detection
29+ FixSpec.purs -- Test auto-fix application
30+```
31+
32+## Test Approach
33+
34+Use `purescript-spec` or simple assertions. Each rule spec should:
35+
36+### 1. Test Detection (Positive Cases)
37+
38+```purescript
39+-- FmapIdSpec.purs
40+testDetectsMapIdentity = do
41+ let result = runRules [fmapIdRule] (SourceCode "module T where\nimport Prelude\nx = map identity [1]")
42+ assertEqual 1 (length result.warnings)
43+ assertEqual "FmapId" (unwrap (head result.warnings).ruleId)
44+```
45+
46+### 2. Test Non-Detection (Negative Cases)
47+
48+```purescript
49+-- Should NOT fire without Prelude import
50+testNoFireWithoutImport = do
51+ let result = runRules [fmapIdRule] (SourceCode "module T where\nx = map identity [1]")
52+ assertEqual 0 (length result.warnings)
53+
54+-- Should NOT fire on non-matching code
55+testNoFireOnOtherCode = do
56+ let result = runRules [fmapIdRule] (SourceCode "module T where\nimport Prelude\nx = map show [1]")
57+ assertEqual 0 (length result.warnings)
58+```
59+
60+### 3. Test Suggestions
61+
62+```purescript
63+testSuggestionCorrect = do
64+ let result = runRules [fmapIdRule] (SourceCode "module T where\nimport Prelude\nx = map identity [1]")
65+ let suggestion = (head result.warnings).suggestion
66+ assertEqual (Just "[1]") (map (unwrap <<< _.replacement) suggestion)
67+```
68+
69+### 4. Test Auto-Fix
70+
71+```purescript
72+testAutoFix = do
73+ let input = "module T where\nimport Prelude\nx = map identity [1]"
74+ let result = runRules [fmapIdRule] (SourceCode input)
75+ let fixed = applyAllFixes input result
76+ assertEqual "module T where\nimport Prelude\nx = [1]" fixed
77+```
78+
79+## Test Cases Per Rule
80+
81+### FmapId
82+- [x] `map identity x` โ†’ `x`
83+- [x] `fmap identity x` โ†’ `x`
84+- [x] `identity <$> x` โ†’ `x`
85+- [x] `x <#> identity` โ†’ `x`
86+- [ ] No fire: `map show x`
87+- [ ] No fire: without Prelude import
88+- [ ] No fire: user-defined `identity`
89+
90+### MapFusion
91+- [x] `map f (map g x)` โ†’ `map (f <<< g) x`
92+- [x] `f <$> (g <$> x)` โ†’ `(f <<< g) <$> x`
93+- [x] `x <#> g <#> f` โ†’ `x <#> (g >>> f)`
94+- [ ] No fire: single map
95+- [ ] No fire: without Prelude import
96+
97+### UseTraverse
98+- [x] `sequenceA (map f x)` โ†’ `traverse f x`
99+- [x] `sequenceA (f <$> x)` โ†’ `traverse f x`
100+- [x] `sequence (map f x)` โ†’ `mapM f x`
101+- [ ] No fire: `sequenceA x` (no map)
102+- [ ] No fire: without imports
103+
104+### ConcatMap
105+- [x] `concat (map f x)` โ†’ `concatMap f x`
106+- [x] `join (map f x)` โ†’ `(=<<) f x`
107+- [ ] No fire: `concat x` (no map)
108+- [ ] No fire: without imports
109+
110+### NotEqual
111+- [x] `not (a == b)` โ†’ `a /= b`
112+- [x] `not (a /= b)` โ†’ `a == b`
113+- [ ] No fire: `not (a > b)`
114+- [ ] No fire: without imports
115+
116+### UseGuard
117+- [x] `if cond then x else pure unit` โ†’ `when cond x`
118+- [x] `if cond then pure unit else x` โ†’ `unless cond x`
119+- [ ] No fire: `if cond then x else y` (neither is pure unit)
120+- [ ] No fire: without imports
121+
122+### EtaReduce
123+- [x] `\x -> f x` โ†’ `f`
124+- [ ] No fire: `\x -> f x x` (x used twice)
125+- [ ] No fire: `\x -> x` (no function application)
126+- [ ] No fire: `\x y -> f x y` (multiple params - only reduce one at a time?)
127+
128+### EtaReduceDecl
129+- [x] `foo x = bar x` โ†’ `foo = bar`
130+- [x] `foo a b = bar a b` โ†’ `foo a = bar a`
131+- [x] Fix includes removing param from LHS
132+- [ ] No fire: `foo x = bar x x`
133+- [ ] No fire: `foo x = x`
134+
135+### RedundantBind
136+- [x] `x >>= pure` โ†’ `x`
137+- [x] `pure x >>= f` โ†’ hint (no auto-fix)
138+- [ ] No fire: `x >>= f`
139+- [ ] No fire: without imports
140+
141+### LetToWhere
142+- [x] `let x = 1 in x + 1` โ†’ `x + 1 where x = 1`
143+- [x] Multiple bindings
144+- [ ] Nested let (should it fire on inner?)
145+
146+## Implementation Steps
147+
148+1. Add `purescript-spec` to dependencies
149+2. Create test infrastructure in `Test.Main`
150+3. Implement specs for each rule (start with FmapId as template)
151+4. Run tests: `spago test` or via Go runner
152+5. Add CI integration
153+
154+## Running Tests
155+
156+```bash
157+# Compile and run
158+purs compile --codegen corefn 'lib/**/*.purs' 'test/**/*.purs' '.spago/**/*.purs'
159+psgo output/*/corefn.json
160+# Build test runner
161+go build -o test-runner ./output/Test.Main
162+./test-runner
163+```
164+
165+## Dependencies to Add
166+
167+```yaml
168+# In test package spago.yaml
169+dependencies:
170+ - spec
171+ - spec-discovery
172+ - aff
173+```
examples/BadCode.pursnew file+29
@@ -0,0 +1,29 @@
1+module Examples.BadCode where
2+
3+import Prelude
4+import Data.Traversable (sequenceA)
5+import Data.Array (concat)
6+
7+-- UseTraverse: sequenceA (map f x) -> traverse f x
8+test1 xs = sequenceA (map pure xs)
9+
10+-- MapFusion: map f (map g x) -> map (f <<< g) x
11+test2 xs = map show (map (_ + 1) xs)
12+
13+-- FmapId: map identity x -> x
14+test3 xs = map identity xs
15+
16+-- NotEqual: not (a == b) -> a /= b
17+test4 a b = not (a == b)
18+
19+-- ConcatMap: concat (map f x) -> concatMap f x
20+test5 xs = concat (map pure xs)
21+
22+-- UseGuard: if cond then x else pure unit -> when cond x
23+test6 cond x = if cond then x else pure unit
24+
25+-- EtaReduce: \x -> f x -> f
26+test7 = \x -> show x
27+
28+-- RedundantBind: x >>= pure -> x
29+test8 mx = mx >>= pure
examples/TestFix.pursnew file+29
@@ -0,0 +1,29 @@
1+module Examples.BadCode where
2+
3+import Prelude
4+import Data.Traversable (sequenceA)
5+import Data.Array (concat)
6+
7+-- UseTraverse: sequenceA (map f x) -> traverse f x
8+test1 xs = traverse pure xs
9+
10+-- MapFusion: map f (map g x) -> map (f <<< g) x
11+test2 xs = map (f <<< g) x
12+
13+-- FmapId: map identity x -> x
14+test3 xs = xs
15+
16+-- NotEqual: not (a == b) -> a /= b
17+test4 a b = a /= b
18+
19+-- ConcatMap: concat (map f x) -> concatMap f x
20+test5 xs = concatMap f x
21+
22+-- UseGuard: if cond then x else pure unit -> when cond x
23+test6 cond x = when cond action
24+
25+-- EtaReduce: \x -> f x -> f
26+test7 = show
27+
28+-- RedundantBind: x >>= pure -> x
29+test8 mx = mx
go.modnew file+17
@@ -0,0 +1,17 @@
1+module project.localhost/example
2+
3+go 1.23.2
4+
5+replace project.localhost/purescript-native/ffi-loader => ./purescript-native
6+
7+replace project.localhost/purescript-native/output => ./output
8+
9+replace github.com/i-am-the-slime/go-ffi => /Users/mark/Developer/go-ffi
10+
11+require (
12+ github.com/dlclark/regexp2 v1.4.0 // indirect
13+ github.com/i-am-the-slime/go-ffi v0.0.0-20250107191809-8aebdb8aad2e // indirect
14+ github.com/purescript-native/go-runtime v0.1.2 // indirect
15+ project.localhost/purescript-native/ffi-loader v0.0.0-00010101000000-000000000000 // indirect
16+ project.localhost/purescript-native/output v0.0.0-00010101000000-000000000000 // indirect
17+)
go.sumnew file+8
@@ -0,0 +1,8 @@
1+github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E=
2+github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
3+github.com/i-am-the-slime/go-ffi v0.0.0-20250107191809-8aebdb8aad2e h1:1XdE49AEa1EpmMPOVMqY7jVaqGUssrdHuSz6gY4LYP0=
4+github.com/i-am-the-slime/go-ffi v0.0.0-20250107191809-8aebdb8aad2e/go.mod h1:G20T9FXpgn6M3s1pnXDAOTwAXKQgDwl/uhMLfo5h6PA=
5+github.com/joprice/purescript-native-go-ffi v0.0.0-20230319170919-df001aa880fd/go.mod h1:1TvaJdjRUFHRmoLQ5QvpR1pnivyd7eBjNA+j1PuktBM=
6+github.com/purescript-native/go-runtime v0.1.0/go.mod h1:Pl7TR9c9Al8GMlY/R2D7joWjlQuTiB9eBN2a9aXd6GQ=
7+github.com/purescript-native/go-runtime v0.1.2 h1:vxVmLXojzc6nkP29qMxHmEd6iwolF309yP/FskRY88Y=
8+github.com/purescript-native/go-runtime v0.1.2/go.mod h1:Pl7TR9c9Al8GMlY/R2D7joWjlQuTiB9eBN2a9aXd6GQ=
lib/purelint/spago.yamlnew file+15
@@ -0,0 +1,15 @@
1+package:
2+ name: purelint
3+ dependencies:
4+ - arrays
5+ - effect
6+ - either
7+ - foldable-traversable
8+ - language-cst-parser
9+ - lists
10+ - maybe
11+ - newtype
12+ - ordered-collections
13+ - prelude
14+ - strings
15+ - tuples
lib/purelint/src/Purelint/Config.pursnew file+41
@@ -0,0 +1,41 @@
1+module Purelint.Config where
2+
3+import Prelude
4+
5+import Data.Array as Array
6+import Data.String as String
7+import Purelint.Types (RuleId(..))
8+
9+-- | Parsed config - just enabled/disabled rules for now
10+type Config =
11+ { disabledRules :: Array RuleId
12+ }
13+
14+defaultConfig :: Config
15+defaultConfig =
16+ { disabledRules: []
17+ }
18+
19+-- | Parse simple line-based config
20+parseConfig :: String -> Config
21+parseConfig str =
22+ let
23+ lines = String.split (String.Pattern "\n") str
24+ nonEmpty = Array.filter (\l -> String.length l > 0 && not (isComment l)) lines
25+ rules = map (RuleId <<< String.trim) nonEmpty
26+ in
27+ { disabledRules: rules }
28+
29+ where
30+ isComment :: String -> Boolean
31+ isComment line =
32+ let trimmed = String.trim line
33+ in String.take 1 trimmed == "#"
34+
35+-- | Check if a rule is enabled given the config
36+isRuleEnabled :: Config -> RuleId -> Boolean
37+isRuleEnabled config ruleId = not (Array.elem ruleId config.disabledRules)
38+
39+-- | Filter rules by config
40+filterRules :: forall a. Array { ruleId :: RuleId | a } -> Config -> Array { ruleId :: RuleId | a }
41+filterRules rules config = Array.filter (\r -> isRuleEnabled config r.ruleId) rules
lib/purelint/src/Purelint/Fix.pursnew file+107
@@ -0,0 +1,107 @@
1+module Purelint.Fix where
2+
3+import Prelude
4+
5+import Data.Array as Array
6+import Data.Maybe (Maybe(..))
7+import Data.Newtype (unwrap)
8+import Data.String as String
9+import Data.String.CodeUnits as StringCU
10+import Purelint.Types (LintResult(..), LintWarning(..), Suggestion(..))
11+import PureScript.CST.Types (SourceRange)
12+
13+-- | Extract text at a source range from source code
14+extractRange :: String -> SourceRange -> String
15+extractRange source range =
16+ let
17+ lines = String.split (String.Pattern "\n") source
18+ startLine = range.start.line
19+ endLine = range.end.line
20+ startCol = range.start.column
21+ endCol = range.end.column
22+ in
23+ if startLine == endLine then
24+ -- Single line extraction
25+ case Array.index lines startLine of
26+ Nothing -> ""
27+ Just line -> StringCU.slice startCol endCol line
28+ else
29+ -- Multi-line extraction
30+ let
31+ firstLine = case Array.index lines startLine of
32+ Nothing -> ""
33+ Just line -> StringCU.drop startCol line
34+ middleLines = Array.slice (startLine + 1) endLine lines
35+ lastLine = case Array.index lines endLine of
36+ Nothing -> ""
37+ Just line -> StringCU.take endCol line
38+ in
39+ String.joinWith "\n" ([firstLine] <> middleLines <> [lastLine])
40+
41+-- | Replace text at a source range with new text
42+replaceRange :: String -> SourceRange -> String -> String
43+replaceRange source range replacement =
44+ let
45+ lines = String.split (String.Pattern "\n") source
46+ startLine = range.start.line
47+ endLine = range.end.line
48+ startCol = range.start.column
49+ endCol = range.end.column
50+ in
51+ if startLine == endLine then
52+ -- Single line replacement
53+ case Array.index lines startLine of
54+ Nothing -> source
55+ Just line ->
56+ let
57+ before = StringCU.take startCol line
58+ after = StringCU.drop endCol line
59+ newLine = before <> replacement <> after
60+ newLines = Array.updateAt startLine newLine lines
61+ in
62+ case newLines of
63+ Nothing -> source
64+ Just ls -> String.joinWith "\n" ls
65+ else
66+ -- Multi-line replacement
67+ let
68+ beforeLines = Array.take startLine lines
69+ afterLines = Array.drop (endLine + 1) lines
70+ firstLinePart = case Array.index lines startLine of
71+ Nothing -> ""
72+ Just line -> StringCU.take startCol line
73+ lastLinePart = case Array.index lines endLine of
74+ Nothing -> ""
75+ Just line -> StringCU.drop endCol line
76+ newLine = firstLinePart <> replacement <> lastLinePart
77+ in
78+ String.joinWith "\n" (beforeLines <> [newLine] <> afterLines)
79+
80+-- | Apply a single fix to source code
81+applyFix :: String -> LintWarning -> Maybe String
82+applyFix source (LintWarning w) = do
83+ Suggestion s <- w.suggestion
84+ let replacement = unwrap s.replacement
85+ Just $ replaceRange source w.range replacement
86+
87+-- | Apply all fixes to source code (in reverse order to preserve ranges)
88+applyAllFixes :: String -> LintResult -> String
89+applyAllFixes source (LintResult result) =
90+ -- Sort warnings by range in reverse order (end of file first)
91+ let
92+ sortedWarnings = Array.sortBy compareRangesReverse result.warnings
93+ in
94+ Array.foldl applyFixIfPossible source sortedWarnings
95+ where
96+ compareRangesReverse :: LintWarning -> LintWarning -> Ordering
97+ compareRangesReverse (LintWarning a) (LintWarning b) =
98+ -- Compare in reverse: later positions first
99+ case compare b.range.start.line a.range.start.line of
100+ EQ -> compare b.range.start.column a.range.start.column
101+ other -> other
102+
103+ applyFixIfPossible :: String -> LintWarning -> String
104+ applyFixIfPossible src warning =
105+ case applyFix src warning of
106+ Nothing -> src
107+ Just fixed -> fixed
lib/purelint/src/Purelint/Imports.pursnew file+140
@@ -0,0 +1,140 @@
1+module Purelint.Imports where
2+
3+import Prelude
4+
5+import Data.Array as Array
6+import Data.Foldable (foldr)
7+import Data.Maybe (Maybe(..))
8+import Data.Newtype (unwrap)
9+import Data.Set (Set)
10+import Data.Set as Set
11+import Data.Tuple (Tuple(..), snd)
12+import Data.Void (Void)
13+import PureScript.CST.Types (DelimitedNonEmpty(..), Import(..), ImportDecl(..), Module(..), ModuleHeader(..), ModuleName(..), Name(..), Ident(..), Operator(..), Separated(..), Wrapped(..))
14+
15+-- | Information about what's imported from which module
16+type ImportInfo =
17+ { -- Values imported (unqualified) from Prelude-like modules
18+ preludeValues :: Set String
19+ -- Operators imported (unqualified) from Prelude-like modules
20+ , preludeOps :: Set String
21+ -- Whether Prelude is imported (open or with specific imports)
22+ , hasPrelude :: Boolean
23+ }
24+
25+-- | Modules considered "Prelude-like" for our purposes
26+preludeModules :: Set String
27+preludeModules = Set.fromFoldable
28+ [ "Prelude"
29+ , "Data.Functor"
30+ , "Data.Foldable"
31+ , "Data.Traversable"
32+ , "Control.Applicative"
33+ , "Control.Monad"
34+ , "Control.Bind"
35+ , "Data.Function"
36+ , "Data.Eq"
37+ , "Data.Boolean"
38+ ]
39+
40+-- | Standard Prelude values we care about
41+standardValues :: Set String
42+standardValues = Set.fromFoldable
43+ [ "map", "fmap", "identity", "id", "not"
44+ , "traverse", "sequenceA", "sequence"
45+ , "concat", "concatMap", "bind", "pure", "join"
46+ , "when", "unless"
47+ ]
48+
49+-- | Standard Prelude operators we care about
50+standardOps :: Set String
51+standardOps = Set.fromFoldable
52+ [ "<$>", "<#>", "<*>", ">>=", "=<<", ">>", "<<<", ">>>", "==", "/=" ]
53+
54+-- | Extract import information from a module
55+getImportInfo :: Module Void -> ImportInfo
56+getImportInfo (Module { header: ModuleHeader header }) =
57+ let
58+ imports = header.imports
59+ results = map analyzeImport imports
60+ in foldr mergeInfo emptyInfo results
61+
62+-- | Merge two ImportInfo records
63+mergeInfo :: ImportInfo -> ImportInfo -> ImportInfo
64+mergeInfo a b =
65+ { preludeValues: Set.union a.preludeValues b.preludeValues
66+ , preludeOps: Set.union a.preludeOps b.preludeOps
67+ , hasPrelude: a.hasPrelude || b.hasPrelude
68+ }
69+
70+-- | Analyze a single import declaration
71+analyzeImport :: ImportDecl Void -> ImportInfo
72+analyzeImport (ImportDecl imp) =
73+ let
74+ moduleName = case imp.module of
75+ Name { name: ModuleName n } -> n
76+ isPreludeLike = Set.member moduleName preludeModules
77+ isQualified = case imp.qualified of
78+ Just _ -> true
79+ Nothing -> false
80+ in
81+ if not isPreludeLike || isQualified then
82+ -- Not a Prelude-like module or it's qualified - ignore
83+ emptyInfo
84+ else case imp.names of
85+ -- Open import: import Prelude
86+ Nothing ->
87+ { preludeValues: standardValues
88+ , preludeOps: standardOps
89+ , hasPrelude: moduleName == "Prelude"
90+ }
91+ -- Selective import: import Prelude (map, (<$>))
92+ Just (Tuple hiding delimited) ->
93+ case hiding of
94+ -- Hiding import - assume everything except hidden is available
95+ Just _ ->
96+ { preludeValues: standardValues
97+ , preludeOps: standardOps
98+ , hasPrelude: moduleName == "Prelude"
99+ }
100+ -- Explicit imports
101+ Nothing ->
102+ let
103+ imps = getDelimitedItems delimited
104+ vals = Array.mapMaybe getImportValue imps
105+ ops = Array.mapMaybe getImportOp imps
106+ in
107+ { preludeValues: Set.fromFoldable vals
108+ , preludeOps: Set.fromFoldable ops
109+ , hasPrelude: moduleName == "Prelude"
110+ }
111+
112+emptyInfo :: ImportInfo
113+emptyInfo =
114+ { preludeValues: Set.empty
115+ , preludeOps: Set.empty
116+ , hasPrelude: false
117+ }
118+
119+-- | Extract items from a DelimitedNonEmpty
120+getDelimitedItems :: forall a. DelimitedNonEmpty a -> Array a
121+getDelimitedItems (Wrapped { value: Separated { head, tail } }) =
122+ Array.cons head (map snd tail)
123+
124+-- | Get value name from Import if it's a value import
125+getImportValue :: Import Void -> Maybe String
126+getImportValue (ImportValue (Name { name: Ident n })) = Just n
127+getImportValue _ = Nothing
128+
129+-- | Get operator name from Import if it's an operator import
130+getImportOp :: Import Void -> Maybe String
131+getImportOp (ImportOp (Name { name: Operator n })) = Just n
132+getImportOp _ = Nothing
133+
134+-- | Check if a value is available (imported from Prelude-like module)
135+hasValue :: ImportInfo -> String -> Boolean
136+hasValue info name = Set.member name info.preludeValues
137+
138+-- | Check if an operator is available (imported from Prelude-like module)
139+hasOp :: ImportInfo -> String -> Boolean
140+hasOp info op = Set.member op info.preludeOps
lib/purelint/src/Purelint/Print.pursnew file+28
@@ -0,0 +1,28 @@
1+module Purelint.Print where
2+
3+import Prelude
4+
5+import Data.Foldable (foldMap)
6+import Data.String as String
7+import Data.String.Regex (regex, replace)
8+import Data.String.Regex.Flags (global)
9+import Data.Either (either)
10+import Data.Void (Void)
11+import PureScript.CST.Print (printSourceToken)
12+import PureScript.CST.Range (class TokensOf, tokensOf)
13+import PureScript.CST.Range.TokenList as TokenList
14+import PureScript.CST.Types (Expr)
15+
16+-- | Print an expression back to source code, normalizing whitespace
17+printExpr :: Expr Void -> String
18+printExpr expr =
19+ let raw = foldMap printSourceToken (TokenList.toArray (tokensOf expr))
20+ in normalizeWhitespace raw
21+
22+-- | Collapse multiple spaces into single space and trim
23+normalizeWhitespace :: String -> String
24+normalizeWhitespace s =
25+ let
26+ -- Replace multiple spaces/newlines with single space
27+ collapsed = either (const s) (\r -> replace r " " s) (regex "\\s+" global)
28+ in String.trim collapsed
lib/purelint/src/Purelint/Rule.pursnew file+23
@@ -0,0 +1,23 @@
1+module Purelint.Rule where
2+
3+import Prelude
4+
5+import Purelint.Imports (ImportInfo)
6+import Purelint.Types (LintWarning, RuleId)
7+import PureScript.CST.Types (Module)
8+import Data.Void (Void)
9+
10+-- | Context passed to each rule containing import information
11+type RuleContext =
12+ { imports :: ImportInfo
13+ }
14+
15+-- | A lint rule that can analyze a CST module and produce warnings
16+type Rule =
17+ { ruleId :: RuleId
18+ , run :: RuleContext -> Module Void -> Array LintWarning
19+ }
20+
21+-- | Create a rule from an id and a function
22+mkRule :: RuleId -> (RuleContext -> Module Void -> Array LintWarning) -> Rule
23+mkRule ruleId run = { ruleId, run }
lib/purelint/src/Purelint/Rules/ConcatMap.pursnew file+84
@@ -0,0 +1,84 @@
1+module Purelint.Rules.ConcatMap where
2+
3+import Prelude
4+
5+import Data.Array.NonEmpty as NEA
6+import Data.Maybe (Maybe(..))
7+import Data.Void (Void)
8+import Purelint.Imports (ImportInfo, hasValue)
9+import Purelint.Print (printExpr)
10+import Purelint.Rule (Rule, RuleContext, mkRule)
11+import Purelint.Types (LintWarning(..), RuleId(..), Severity(..), Suggestion(..), SuggestionDescription(..), ReplacementText(..), WarningMessage(..))
12+import PureScript.CST.Range (rangeOf)
13+import PureScript.CST.Traversal (defaultMonoidalVisitor, foldMapModule)
14+import PureScript.CST.Types (AppSpine(..), Expr(..), Ident(..), Module, QualifiedName(..), Wrapped(..))
15+
16+-- | Rule: concat (map f x) -> concatMap f x
17+-- | Also: join (map f x) -> (=<<) f x (for Monads)
18+concatMapRule :: Rule
19+concatMapRule = mkRule (RuleId "ConcatMap") run
20+ where
21+ run :: RuleContext -> Module Void -> Array LintWarning
22+ run ctx = foldMapModule visitor
23+ where
24+ imports = ctx.imports
25+ visitor = defaultMonoidalVisitor { onExpr = checkExpr imports }
26+
27+ checkExpr :: ImportInfo -> Expr Void -> Array LintWarning
28+ checkExpr imports expr = case expr of
29+ -- Match: concat (map f x) or join (map f x)
30+ ExprApp fnExpr args ->
31+ case fnExpr of
32+ ExprIdent qn | isConcatOrJoin imports qn ->
33+ case NEA.head args of
34+ AppTerm innerExpr -> checkInnerMap imports expr qn (unwrapParens innerExpr)
35+ _ -> []
36+ _ -> []
37+ _ -> []
38+
39+ unwrapParens :: Expr Void -> Expr Void
40+ unwrapParens (ExprParens (Wrapped { value })) = unwrapParens value
41+ unwrapParens e = e
42+
43+ checkInnerMap :: ImportInfo -> Expr Void -> QualifiedName Ident -> Expr Void -> Array LintWarning
44+ checkInnerMap imports fullExpr fnQn innerExpr =
45+ case innerExpr of
46+ ExprApp mapFn innerArgs | isMapApp imports mapFn ->
47+ case NEA.toArray innerArgs of
48+ [AppTerm fExpr, AppTerm xExpr] ->
49+ let
50+ fnName = getIdentName fnQn
51+ replacementFn = if fnName == "concat" then "concatMap" else "(=<<)"
52+ f = printExpr fExpr
53+ x = printExpr xExpr
54+ replacement = replacementFn <> " " <> f <> " " <> x
55+ msg = fnName <> " (map f x) can be replaced with " <> replacementFn <> " f x"
56+ in
57+ [ LintWarning
58+ { ruleId: RuleId "ConcatMap"
59+ , message: WarningMessage $ "Use " <> replacementFn <> " instead of " <> fnName <> " composed with map"
60+ , range: rangeOf fullExpr
61+ , severity: Hint
62+ , suggestion: Just $ Suggestion
63+ { replacement: ReplacementText replacement
64+ , description: SuggestionDescription msg
65+ }
66+ }
67+ ]
68+ _ -> []
69+ _ -> []
70+
71+ isMapApp :: ImportInfo -> Expr Void -> Boolean
72+ isMapApp imports (ExprIdent qn) = isMap imports qn
73+ isMapApp _ _ = false
74+
75+ isMap :: ImportInfo -> QualifiedName Ident -> Boolean
76+ isMap imports (QualifiedName { name: Ident name }) =
77+ (name == "map" && hasValue imports "map") || (name == "fmap" && hasValue imports "fmap")
78+
79+ isConcatOrJoin :: ImportInfo -> QualifiedName Ident -> Boolean
80+ isConcatOrJoin imports (QualifiedName { name: Ident name }) =
81+ (name == "concat" && hasValue imports "concat") || (name == "join" && hasValue imports "join")
82+
83+ getIdentName :: QualifiedName Ident -> String
84+ getIdentName (QualifiedName { name: Ident name }) = name
lib/purelint/src/Purelint/Rules/EtaReduce.pursnew file+82
@@ -0,0 +1,82 @@
1+module Purelint.Rules.EtaReduce where
2+
3+import Prelude
4+
5+import Data.Array as Array
6+import Data.Array.NonEmpty as NEA
7+import Data.Maybe (Maybe(..))
8+import Purelint.Print (printExpr)
9+import Purelint.Rule (Rule, RuleContext, mkRule)
10+import Purelint.Types (LintWarning(..), RuleId(..), Severity(..), Suggestion(..), SuggestionDescription(..), ReplacementText(..), WarningMessage(..))
11+import PureScript.CST.Range (rangeOf)
12+import PureScript.CST.Traversal (defaultMonoidalVisitor, foldMapModule)
13+import PureScript.CST.Types (AppSpine(..), Binder(..), Expr(..), Ident(..), Module, Name(..), QualifiedName(..), Wrapped(..))
14+import Data.Void (Void)
15+
16+-- | Rule: \x -> f x -> f (eta reduction)
17+-- | Detects simple lambda functions that can be eta reduced
18+etaReduceRule :: Rule
19+etaReduceRule = mkRule (RuleId "EtaReduce") run
20+ where
21+ run :: RuleContext -> Module Void -> Array LintWarning
22+ run _ctx = foldMapModule visitor
23+ where
24+ visitor = defaultMonoidalVisitor { onExpr = checkExpr }
25+
26+ checkExpr :: Expr Void -> Array LintWarning
27+ checkExpr expr = case expr of
28+ -- Match: \x -> f x
29+ ExprLambda lambda ->
30+ checkLambda expr lambda
31+ _ -> []
32+
33+ checkLambda :: Expr Void -> _ -> Array LintWarning
34+ checkLambda fullExpr lambda =
35+ -- Check for single binder that's a simple variable
36+ case NEA.toArray lambda.binders of
37+ [BinderVar (Name { name: Ident paramName })] ->
38+ -- Check if body is f x where x matches the param
39+ checkBody fullExpr paramName (unwrapParens lambda.body)
40+ _ -> []
41+
42+ checkBody :: Expr Void -> String -> Expr Void -> Array LintWarning
43+ checkBody fullExpr paramName body =
44+ case body of
45+ ExprApp fn args ->
46+ -- Check if the last argument is just our parameter
47+ let argsArr = NEA.toArray args in
48+ case Array.last argsArr of
49+ Just (AppTerm (ExprIdent (QualifiedName { name: Ident argName })))
50+ | argName == paramName && not (mentionsParam paramName fn) && Array.length argsArr == 1 ->
51+ -- Single arg case: \x -> f x -> f
52+ let fnText = printExpr fn
53+ in
54+ [ LintWarning
55+ { ruleId: RuleId "EtaReduce"
56+ , message: WarningMessage "Lambda can be eta reduced"
57+ , range: rangeOf fullExpr
58+ , severity: Hint
59+ , suggestion: Just $ Suggestion
60+ { replacement: ReplacementText fnText
61+ , description: SuggestionDescription $ "\\x -> f x can be simplified to f"
62+ }
63+ }
64+ ]
65+ _ -> []
66+ _ -> []
67+
68+ unwrapParens :: Expr Void -> Expr Void
69+ unwrapParens (ExprParens (Wrapped { value })) = unwrapParens value
70+ unwrapParens e = e
71+
72+ -- Check if expression mentions the parameter (crude check)
73+ mentionsParam :: String -> Expr Void -> Boolean
74+ mentionsParam name (ExprIdent (QualifiedName { name: Ident n })) = n == name
75+ mentionsParam name (ExprApp fn args) =
76+ mentionsParam name fn || Array.any (checkArg name) (NEA.toArray args)
77+ mentionsParam name (ExprParens (Wrapped { value })) = mentionsParam name value
78+ mentionsParam _ _ = false
79+
80+ checkArg :: String -> AppSpine Expr Void -> Boolean
81+ checkArg name (AppTerm e) = mentionsParam name e
82+ checkArg _ _ = false
lib/purelint/src/Purelint/Rules/EtaReduceDecl.pursnew file+102
@@ -0,0 +1,102 @@
1+module Purelint.Rules.EtaReduceDecl where
2+
3+import Prelude
4+
5+import Data.Array as Array
6+import Data.Array.NonEmpty as NEA
7+import Data.Maybe (Maybe(..))
8+import Data.String as String
9+import Data.Void (Void)
10+import Purelint.Print (printExpr)
11+import Purelint.Rule (Rule, RuleContext, mkRule)
12+import Purelint.Types (LintWarning(..), RuleId(..), Severity(..), Suggestion(..), SuggestionDescription(..), ReplacementText(..), WarningMessage(..))
13+import PureScript.CST.Range (rangeOf)
14+import PureScript.CST.Traversal (defaultMonoidalVisitor, foldMapModule)
15+import PureScript.CST.Types as CST
16+import PureScript.CST.Types (AppSpine(..), Binder(..), Declaration(..), Expr(..), Guarded(..), Ident(..), Module, Name(..), QualifiedName(..), ValueBindingFields, Where(..), Wrapped(..))
17+
18+-- | Rule: foo x = bar x -> foo = bar (top-level eta reduction)
19+etaReduceDeclRule :: Rule
20+etaReduceDeclRule = mkRule (RuleId "EtaReduceDecl") run
21+ where
22+ run :: RuleContext -> Module Void -> Array LintWarning
23+ run _ctx = foldMapModule visitor
24+ where
25+ visitor = defaultMonoidalVisitor { onDecl = checkDecl }
26+
27+ checkDecl :: Declaration Void -> Array LintWarning
28+ checkDecl decl = case decl of
29+ DeclValue vb -> checkValueBinding vb
30+ _ -> []
31+
32+ checkValueBinding :: ValueBindingFields Void -> Array LintWarning
33+ checkValueBinding vb =
34+ -- Check for pattern: foo x = expr x (single binder, unconditional)
35+ case vb.guarded of
36+ Unconditional _ (Where { expr, bindings: Nothing }) ->
37+ checkEtaReducible vb.binders (unwrapParens expr)
38+ _ -> []
39+
40+ checkEtaReducible :: Array (Binder Void) -> Expr Void -> Array LintWarning
41+ checkEtaReducible binders body =
42+ -- Get the last binder if it's a simple variable
43+ case Array.last binders of
44+ Just lastBinder@(BinderVar (Name { name: Ident paramName })) ->
45+ -- Check if body is `f ... x` where x is our param
46+ case body of
47+ ExprApp fn args ->
48+ let argsArr = NEA.toArray args in
49+ case Array.last argsArr of
50+ Just (AppTerm (ExprIdent (QualifiedName { name: Ident argName })))
51+ | argName == paramName
52+ && not (mentionsParam paramName fn)
53+ && not (Array.any (checkArgMentions paramName) (Array.dropEnd 1 argsArr)) ->
54+ -- Can eta reduce - build the new RHS
55+ let
56+ remainingArgs = Array.dropEnd 1 argsArr
57+ newRhs = if Array.null remainingArgs
58+ then printExpr fn
59+ else printExpr fn <> " " <> Array.intercalate " " (map printAppSpine remainingArgs)
60+ -- Range from last binder to end of body
61+ binderRange = rangeOf lastBinder
62+ bodyRange = rangeOf body
63+ fullRange = { start: binderRange.start, end: bodyRange.end }
64+ in
65+ [ LintWarning
66+ { ruleId: RuleId "EtaReduceDecl"
67+ , message: WarningMessage "Declaration can be eta reduced"
68+ , range: fullRange
69+ , severity: Hint
70+ , suggestion: Just $ Suggestion
71+ { replacement: ReplacementText ("= " <> newRhs)
72+ , description: SuggestionDescription "Remove redundant parameter"
73+ }
74+ }
75+ ]
76+ _ -> []
77+ _ -> []
78+ _ -> []
79+
80+ printAppSpine :: AppSpine Expr Void -> String
81+ printAppSpine (AppTerm e) = printExpr e
82+ printAppSpine (AppType _ _) = "@_"
83+
84+ checkArgMentions :: String -> AppSpine Expr Void -> Boolean
85+ checkArgMentions name (AppTerm e) = mentionsParam name e
86+ checkArgMentions _ _ = false
87+
88+ unwrapParens :: Expr Void -> Expr Void
89+ unwrapParens (ExprParens (Wrapped { value })) = unwrapParens value
90+ unwrapParens e = e
91+
92+ -- Check if expression mentions the parameter
93+ mentionsParam :: String -> Expr Void -> Boolean
94+ mentionsParam name (ExprIdent (QualifiedName { name: Ident n })) = n == name
95+ mentionsParam name (ExprApp fn args) =
96+ mentionsParam name fn || Array.any (checkArg name) (NEA.toArray args)
97+ mentionsParam name (ExprParens (Wrapped { value })) = mentionsParam name value
98+ mentionsParam _ _ = false
99+
100+ checkArg :: String -> AppSpine Expr Void -> Boolean
101+ checkArg name (AppTerm e) = mentionsParam name e
102+ checkArg _ _ = false
lib/purelint/src/Purelint/Rules/FmapId.pursnew file+105
@@ -0,0 +1,105 @@
1+module Purelint.Rules.FmapId where
2+
3+import Prelude
4+
5+import Data.Array as Array
6+import Data.Array.NonEmpty as NEA
7+import Data.Maybe (Maybe(..))
8+import Data.Tuple (Tuple(..))
9+import Data.Void (Void)
10+import Purelint.Imports (ImportInfo, hasValue, hasOp)
11+import Purelint.Print (printExpr)
12+import Purelint.Rule (Rule, RuleContext, mkRule)
13+import Purelint.Types (LintWarning(..), RuleId(..), Severity(..), Suggestion(..), SuggestionDescription(..), ReplacementText(..), WarningMessage(..))
14+import PureScript.CST.Range (rangeOf)
15+import PureScript.CST.Traversal (defaultMonoidalVisitor, foldMapModule)
16+import PureScript.CST.Types (AppSpine(..), Expr(..), Ident(..), Module, Operator(..), QualifiedName(..))
17+
18+-- | Rule: map identity x -> x
19+-- | Also: fmap identity x -> x, identity <$> x -> x, x <#> identity -> x
20+fmapIdRule :: Rule
21+fmapIdRule = mkRule (RuleId "FmapId") run
22+ where
23+ run :: RuleContext -> Module Void -> Array LintWarning
24+ run ctx = foldMapModule visitor
25+ where
26+ imports = ctx.imports
27+ visitor = defaultMonoidalVisitor { onExpr = checkExpr imports }
28+
29+ checkExpr :: ImportInfo -> Expr Void -> Array LintWarning
30+ checkExpr imports expr = case expr of
31+ -- Match: map identity x or fmap identity x
32+ ExprApp fnExpr args ->
33+ case fnExpr of
34+ ExprIdent qn | isMapLike imports qn ->
35+ case NEA.toArray args of
36+ [AppTerm idExpr, AppTerm xExpr] | isIdentity imports idExpr ->
37+ let xText = printExpr xExpr
38+ in
39+ [ LintWarning
40+ { ruleId: RuleId "FmapId"
41+ , message: WarningMessage "map identity is redundant"
42+ , range: rangeOf expr
43+ , severity: Warning
44+ , suggestion: Just $ Suggestion
45+ { replacement: ReplacementText xText
46+ , description: SuggestionDescription "map identity x can be simplified to x"
47+ }
48+ }
49+ ]
50+ _ -> []
51+ _ -> []
52+ -- Match: identity <$> x
53+ ExprOp lhs ops | isIdentity imports lhs ->
54+ case NEA.toArray ops of
55+ [Tuple qn xExpr] | isMapOp imports qn ->
56+ let xText = printExpr xExpr
57+ in
58+ [ LintWarning
59+ { ruleId: RuleId "FmapId"
60+ , message: WarningMessage "identity <$> is redundant"
61+ , range: rangeOf expr
62+ , severity: Warning
63+ , suggestion: Just $ Suggestion
64+ { replacement: ReplacementText xText
65+ , description: SuggestionDescription "identity <$> x can be simplified to x"
66+ }
67+ }
68+ ]
69+ _ -> []
70+ -- Match: x <#> identity
71+ ExprOp lhs ops ->
72+ case NEA.toArray ops of
73+ [Tuple qn idExpr] | isFlippedMapOp imports qn && isIdentity imports idExpr ->
74+ let xText = printExpr lhs
75+ in
76+ [ LintWarning
77+ { ruleId: RuleId "FmapId"
78+ , message: WarningMessage "<#> identity is redundant"
79+ , range: rangeOf expr
80+ , severity: Warning
81+ , suggestion: Just $ Suggestion
82+ { replacement: ReplacementText xText
83+ , description: SuggestionDescription "x <#> identity can be simplified to x"
84+ }
85+ }
86+ ]
87+ _ -> []
88+ _ -> []
89+
90+ isMapLike :: ImportInfo -> QualifiedName Ident -> Boolean
91+ isMapLike imports (QualifiedName { name: Ident name }) =
92+ (name == "map" && hasValue imports "map") || (name == "fmap" && hasValue imports "fmap")
93+
94+ isMapOp :: ImportInfo -> QualifiedName Operator -> Boolean
95+ isMapOp imports (QualifiedName { name: Operator op }) =
96+ (op == "<$>" || op == "<$$>") && hasOp imports "<$>"
97+
98+ isFlippedMapOp :: ImportInfo -> QualifiedName Operator -> Boolean
99+ isFlippedMapOp imports (QualifiedName { name: Operator op }) =
100+ (op == "<#>" || op == "<##>") && hasOp imports "<#>"
101+
102+ isIdentity :: ImportInfo -> Expr Void -> Boolean
103+ isIdentity imports (ExprIdent (QualifiedName { name: Ident name })) =
104+ (name == "identity" && hasValue imports "identity") || (name == "id" && hasValue imports "id")
105+ isIdentity _ _ = false
lib/purelint/src/Purelint/Rules/LetToWhere.pursnew file+53
@@ -0,0 +1,53 @@
1+module Purelint.Rules.LetToWhere where
2+
3+import Prelude
4+
5+import Data.Array.NonEmpty as NEA
6+import Data.Foldable (foldMap)
7+import Data.Maybe (Maybe(..))
8+import Data.String (joinWith) as String
9+import Purelint.Rule (Rule, RuleContext, mkRule)
10+import Purelint.Types (LintWarning(..), RuleId(..), Severity(..), Suggestion(..), SuggestionDescription(..), ReplacementText(..), WarningMessage(..))
11+import PureScript.CST.Print (printToken)
12+import PureScript.CST.Range (rangeOf, tokensOf)
13+import PureScript.CST.Range.TokenList as TokenList
14+import PureScript.CST.Traversal (defaultMonoidalVisitor, foldMapModule)
15+import PureScript.CST.Types (Expr(..), LetBinding, Module)
16+
17+-- | Rule: let x = y in z -> z where x = y
18+-- | Suggests replacing let...in expressions with where clauses
19+letToWhereRule :: Rule
20+letToWhereRule = mkRule (RuleId "LetToWhere") run
21+ where
22+ run :: RuleContext -> Module Void -> Array LintWarning
23+ run _ctx = foldMapModule visitor
24+ where
25+ visitor = defaultMonoidalVisitor { onExpr = checkExpr }
26+
27+ checkExpr :: Expr Void -> Array LintWarning
28+ checkExpr expr = case expr of
29+ ExprLet letIn ->
30+ let
31+ bodyText = printExpr letIn.body
32+ bindings = NEA.toArray letIn.bindings # map printBinding
33+ bindingsText = String.joinWith "\n " bindings
34+ replacement = bodyText <> "\n where\n " <> bindingsText
35+ in
36+ [ LintWarning
37+ { ruleId: RuleId "LetToWhere"
38+ , message: WarningMessage "Use where clause instead of let...in"
39+ , range: rangeOf expr
40+ , severity: Hint
41+ , suggestion: Just $ Suggestion
42+ { replacement: ReplacementText replacement
43+ , description: SuggestionDescription "let x = y in z can be rewritten as z where x = y"
44+ }
45+ }
46+ ]
47+ _ -> []
48+
49+ printExpr :: Expr Void -> String
50+ printExpr e = foldMap (\tok -> printToken tok.value) (TokenList.toArray (tokensOf e))
51+
52+ printBinding :: LetBinding Void -> String
53+ printBinding b = foldMap (\tok -> printToken tok.value) (TokenList.toArray (tokensOf b))
lib/purelint/src/Purelint/Rules/MapFusion.pursnew file+142
@@ -0,0 +1,142 @@
1+module Purelint.Rules.MapFusion where
2+
3+import Prelude
4+
5+import Data.Array.NonEmpty as NEA
6+import Data.Maybe (Maybe(..))
7+import Data.Tuple (Tuple(..))
8+import Data.Void (Void)
9+import Purelint.Imports (ImportInfo, hasValue, hasOp)
10+import Purelint.Print (printExpr)
11+import Purelint.Rule (Rule, RuleContext, mkRule)
12+import Purelint.Types (LintWarning(..), RuleId(..), Severity(..), Suggestion(..), SuggestionDescription(..), ReplacementText(..), WarningMessage(..))
13+import PureScript.CST.Range (rangeOf)
14+import PureScript.CST.Traversal (defaultMonoidalVisitor, foldMapModule)
15+import PureScript.CST.Types (AppSpine(..), Expr(..), Ident(..), Module, Operator(..), QualifiedName(..), Wrapped(..))
16+
17+-- | Rule: map f (map g x) -> map (f <<< g) x
18+-- | Also: f <$> (g <$> x) -> (f <<< g) <$> x
19+-- | Also: x <#> g <#> f -> x <#> (g >>> f)
20+mapFusionRule :: Rule
21+mapFusionRule = mkRule (RuleId "MapFusion") run
22+ where
23+ run :: RuleContext -> Module Void -> Array LintWarning
24+ run ctx = foldMapModule visitor
25+ where
26+ imports = ctx.imports
27+ visitor = defaultMonoidalVisitor { onExpr = checkExpr imports }
28+
29+ checkExpr :: ImportInfo -> Expr Void -> Array LintWarning
30+ checkExpr imports expr = case expr of
31+ -- Match: map f (map g x)
32+ ExprApp fnExpr args ->
33+ case fnExpr of
34+ ExprIdent qn | isMap imports qn ->
35+ -- Check if we have at least 2 args and the second is map g x
36+ case NEA.toArray args of
37+ [AppTerm fExpr, AppTerm innerExpr] ->
38+ checkInnerMap imports expr fExpr (unwrapParens innerExpr)
39+ _ -> []
40+ _ -> []
41+ -- Match: f <$> (g <$> x) or f <$> g <$> x
42+ ExprOp fExpr ops | NEA.length ops >= 1 ->
43+ checkOpChain imports expr fExpr (NEA.toArray ops)
44+ _ -> []
45+
46+ unwrapParens :: Expr Void -> Expr Void
47+ unwrapParens (ExprParens (Wrapped { value })) = unwrapParens value
48+ unwrapParens e = e
49+
50+ -- Check function application form: map f (map g x)
51+ checkInnerMap :: ImportInfo -> Expr Void -> Expr Void -> Expr Void -> Array LintWarning
52+ checkInnerMap imports fullExpr fExpr innerExpr =
53+ case innerExpr of
54+ ExprApp mapFn innerArgs | isMapApp imports mapFn ->
55+ case NEA.toArray innerArgs of
56+ [AppTerm gExpr, AppTerm xExpr] ->
57+ mkFusionWarning fullExpr fExpr gExpr xExpr "map"
58+ _ -> []
59+ -- Also check: map f (g <$> x)
60+ ExprOp gExpr innerOps ->
61+ case NEA.toArray innerOps of
62+ [Tuple qn xExpr] | isMapOp imports qn ->
63+ mkFusionWarning fullExpr fExpr gExpr xExpr "map"
64+ _ -> []
65+ _ -> []
66+
67+ -- Check operator form: f <$> (g <$> x) or f <$> g <$> x
68+ checkOpChain :: ImportInfo -> Expr Void -> Expr Void -> Array (Tuple (QualifiedName Operator) (Expr Void)) -> Array LintWarning
69+ checkOpChain imports fullExpr fExpr ops =
70+ case ops of
71+ -- f <$> (g <$> x) - inner is parenthesized
72+ [Tuple op1 innerExpr] | isMapOp imports op1 ->
73+ case unwrapParens innerExpr of
74+ ExprOp gExpr innerOps ->
75+ case NEA.toArray innerOps of
76+ [Tuple op2 xExpr] | isMapOp imports op2 ->
77+ mkFusionWarning fullExpr fExpr gExpr xExpr "<$>"
78+ _ -> []
79+ ExprApp mapFn innerArgs | isMapApp imports mapFn ->
80+ case NEA.toArray innerArgs of
81+ [AppTerm gExpr, AppTerm xExpr] ->
82+ mkFusionWarning fullExpr fExpr gExpr xExpr "<$>"
83+ _ -> []
84+ _ -> []
85+ -- x <#> g <#> f - flipped map chain
86+ [Tuple op1 gExpr, Tuple op2 fExpr'] | isFlippedMapOp imports op1 && isFlippedMapOp imports op2 ->
87+ let
88+ x = printExpr fExpr -- fExpr is actually x in this case
89+ g = printExpr gExpr
90+ f = printExpr fExpr'
91+ replacement = x <> " <#> (" <> g <> " >>> " <> f <> ")"
92+ in
93+ [ LintWarning
94+ { ruleId: RuleId "MapFusion"
95+ , message: WarningMessage "Fuse nested maps using composition"
96+ , range: rangeOf fullExpr
97+ , severity: Hint
98+ , suggestion: Just $ Suggestion
99+ { replacement: ReplacementText replacement
100+ , description: SuggestionDescription "x <#> g <#> f can be replaced with x <#> (g >>> f)"
101+ }
102+ }
103+ ]
104+ _ -> []
105+
106+ mkFusionWarning :: Expr Void -> Expr Void -> Expr Void -> Expr Void -> String -> Array LintWarning
107+ mkFusionWarning fullExpr fExpr gExpr xExpr style =
108+ let
109+ f = printExpr fExpr
110+ g = printExpr gExpr
111+ x = printExpr xExpr
112+ replacement = case style of
113+ "<$>" -> "(" <> f <> " <<< " <> g <> ") <$> " <> x
114+ _ -> "map (" <> f <> " <<< " <> g <> ") " <> x
115+ in
116+ [ LintWarning
117+ { ruleId: RuleId "MapFusion"
118+ , message: WarningMessage "Fuse nested maps using composition"
119+ , range: rangeOf fullExpr
120+ , severity: Hint
121+ , suggestion: Just $ Suggestion
122+ { replacement: ReplacementText replacement
123+ , description: SuggestionDescription "Nested maps can be fused with composition"
124+ }
125+ }
126+ ]
127+
128+ isMapApp :: ImportInfo -> Expr Void -> Boolean
129+ isMapApp imports (ExprIdent qn) = isMap imports qn
130+ isMapApp _ _ = false
131+
132+ isMap :: ImportInfo -> QualifiedName Ident -> Boolean
133+ isMap imports (QualifiedName { name: Ident name }) =
134+ (name == "map" && hasValue imports "map") || (name == "fmap" && hasValue imports "fmap")
135+
136+ isMapOp :: ImportInfo -> QualifiedName Operator -> Boolean
137+ isMapOp imports (QualifiedName { name: Operator op }) =
138+ (op == "<$>" || op == "<$$>") && hasOp imports "<$>"
139+
140+ isFlippedMapOp :: ImportInfo -> QualifiedName Operator -> Boolean
141+ isFlippedMapOp imports (QualifiedName { name: Operator op }) =
142+ (op == "<#>" || op == "<##>") && hasOp imports "<#>"
lib/purelint/src/Purelint/Rules/NotEqual.pursnew file+82
@@ -0,0 +1,82 @@
1+module Purelint.Rules.NotEqual where
2+
3+import Prelude
4+
5+import Data.Array.NonEmpty as NEA
6+import Data.Maybe (Maybe(..))
7+import Data.Tuple (Tuple(..))
8+import Purelint.Imports (ImportInfo, hasValue, hasOp)
9+import Purelint.Print (printExpr)
10+import Purelint.Rule (Rule, RuleContext, mkRule)
11+import Purelint.Types (LintWarning(..), RuleId(..), Severity(..), Suggestion(..), SuggestionDescription(..), ReplacementText(..), WarningMessage(..))
12+import PureScript.CST.Range (rangeOf)
13+import PureScript.CST.Traversal (defaultMonoidalVisitor, foldMapModule)
14+import PureScript.CST.Types (AppSpine(..), Expr(..), Ident(..), Module, Operator(..), QualifiedName(..), Wrapped(..))
15+import Data.Void (Void)
16+
17+-- | Rule: not (a == b) -> a /= b
18+-- | Also: not (a /= b) -> a == b
19+notEqualRule :: Rule
20+notEqualRule = mkRule (RuleId "NotEqual") run
21+ where
22+ run :: RuleContext -> Module Void -> Array LintWarning
23+ run ctx = foldMapModule visitor
24+ where
25+ imports = ctx.imports
26+ visitor = defaultMonoidalVisitor { onExpr = checkExpr imports }
27+
28+ checkExpr :: ImportInfo -> Expr Void -> Array LintWarning
29+ checkExpr imports expr = case expr of
30+ -- Match: not (a == b) or not (a /= b)
31+ ExprApp fnExpr args ->
32+ case fnExpr of
33+ ExprIdent qn | isNot imports qn ->
34+ case NEA.head args of
35+ AppTerm innerExpr -> checkNotComparison imports expr (unwrapParens innerExpr)
36+ _ -> []
37+ _ -> []
38+ _ -> []
39+
40+ unwrapParens :: Expr Void -> Expr Void
41+ unwrapParens (ExprParens (Wrapped { value })) = unwrapParens value
42+ unwrapParens e = e
43+
44+ checkNotComparison :: ImportInfo -> Expr Void -> Expr Void -> Array LintWarning
45+ checkNotComparison imports fullExpr innerExpr =
46+ case innerExpr of
47+ -- Match: a == b or a /= b
48+ ExprOp lhs ops | Just opInfo <- getComparisonOp imports ops ->
49+ let
50+ (Tuple opName suggested) = opInfo
51+ lhsText = printExpr lhs
52+ -- Get RHS from the ops
53+ rhsText = case NEA.toArray ops of
54+ [Tuple _ rhs] -> printExpr rhs
55+ _ -> "b"
56+ replacement = lhsText <> " " <> suggested <> " " <> rhsText
57+ msg = "not (a " <> opName <> " b) can be simplified to a " <> suggested <> " b"
58+ in
59+ [ LintWarning
60+ { ruleId: RuleId "NotEqual"
61+ , message: WarningMessage $ "Use " <> suggested <> " instead of not ... " <> opName
62+ , range: rangeOf fullExpr
63+ , severity: Warning
64+ , suggestion: Just $ Suggestion
65+ { replacement: ReplacementText replacement
66+ , description: SuggestionDescription msg
67+ }
68+ }
69+ ]
70+ _ -> []
71+
72+ getComparisonOp :: ImportInfo -> _ -> Maybe (Tuple String String)
73+ getComparisonOp imports ops =
74+ let arr = NEA.toArray ops
75+ in case arr of
76+ [Tuple (QualifiedName { name: Operator op }) _]
77+ | op == "==" && hasOp imports "==" -> Just (Tuple "==" "/=")
78+ | op == "/=" && hasOp imports "/=" -> Just (Tuple "/=" "==")
79+ _ -> Nothing
80+
81+ isNot :: ImportInfo -> QualifiedName Ident -> Boolean
82+ isNot imports (QualifiedName { name: Ident name }) = name == "not" && hasValue imports "not"
lib/purelint/src/Purelint/Rules/RedundantBind.pursnew file+76
@@ -0,0 +1,76 @@
1+module Purelint.Rules.RedundantBind where
2+
3+import Prelude
4+
5+import Data.Array.NonEmpty as NEA
6+import Data.Maybe (Maybe(..))
7+import Data.Tuple (Tuple(..))
8+import Purelint.Imports (ImportInfo, hasValue, hasOp)
9+import Purelint.Print (printExpr)
10+import Purelint.Rule (Rule, RuleContext, mkRule)
11+import Purelint.Types (LintWarning(..), RuleId(..), Severity(..), Suggestion(..), SuggestionDescription(..), ReplacementText(..), WarningMessage(..))
12+import PureScript.CST.Range (rangeOf)
13+import PureScript.CST.Traversal (defaultMonoidalVisitor, foldMapModule)
14+import PureScript.CST.Types (Expr(..), Ident(..), Module, Operator(..), QualifiedName(..))
15+import Data.Void (Void)
16+
17+-- | Rule: x >>= pure -> x
18+-- | Also: pure x >>= f -> f x
19+redundantBindRule :: Rule
20+redundantBindRule = mkRule (RuleId "RedundantBind") run
21+ where
22+ run :: RuleContext -> Module Void -> Array LintWarning
23+ run ctx = foldMapModule visitor
24+ where
25+ imports = ctx.imports
26+ visitor = defaultMonoidalVisitor { onExpr = checkExpr imports }
27+
28+ checkExpr :: ImportInfo -> Expr Void -> Array LintWarning
29+ checkExpr imports expr = case expr of
30+ -- Match: x >>= pure or pure >>= f
31+ ExprOp lhs ops ->
32+ case NEA.toArray ops of
33+ [Tuple (QualifiedName { name: Operator op }) rhs]
34+ | op == ">>=" && hasOp imports ">>=" -> checkBindOp imports expr lhs rhs
35+ | op == "=<<" && hasOp imports ">>=" -> checkBindOp imports expr rhs lhs
36+ _ -> []
37+ _ -> []
38+
39+ checkBindOp :: ImportInfo -> Expr Void -> Expr Void -> Expr Void -> Array LintWarning
40+ checkBindOp imports fullExpr lhs rhs =
41+ if isPure imports rhs then
42+ -- x >>= pure -> x
43+ let lhsText = printExpr lhs
44+ in
45+ [ LintWarning
46+ { ruleId: RuleId "RedundantBind"
47+ , message: WarningMessage "x >>= pure is redundant"
48+ , range: rangeOf fullExpr
49+ , severity: Warning
50+ , suggestion: Just $ Suggestion
51+ { replacement: ReplacementText lhsText
52+ , description: SuggestionDescription "x >>= pure can be simplified to x"
53+ }
54+ }
55+ ]
56+ else if isPureApp imports lhs then
57+ -- pure x >>= f -> f x (don't auto-fix this one, too complex)
58+ [ LintWarning
59+ { ruleId: RuleId "RedundantBind"
60+ , message: WarningMessage "pure x >>= f can be simplified"
61+ , range: rangeOf fullExpr
62+ , severity: Hint
63+ , suggestion: Nothing -- No auto-fix for this pattern
64+ }
65+ ]
66+ else
67+ []
68+
69+ isPure :: ImportInfo -> Expr Void -> Boolean
70+ isPure imports (ExprIdent (QualifiedName { name: Ident name })) =
71+ (name == "pure" && hasValue imports "pure") || (name == "return" && hasValue imports "pure")
72+ isPure _ _ = false
73+
74+ isPureApp :: ImportInfo -> Expr Void -> Boolean
75+ isPureApp imports (ExprApp fn _) = isPure imports fn
76+ isPureApp _ _ = false
lib/purelint/src/Purelint/Rules/UseGuard.pursnew file+99
@@ -0,0 +1,99 @@
1+module Purelint.Rules.UseGuard where
2+
3+import Prelude
4+
5+import Data.Array.NonEmpty as NEA
6+import Data.Maybe (Maybe(..))
7+import Data.Void (Void)
8+import Purelint.Imports (ImportInfo, hasValue)
9+import Purelint.Print (printExpr)
10+import Purelint.Rule (Rule, RuleContext, mkRule)
11+import Purelint.Types (LintWarning(..), RuleId(..), Severity(..), Suggestion(..), SuggestionDescription(..), ReplacementText(..), WarningMessage(..))
12+import PureScript.CST.Range (rangeOf)
13+import PureScript.CST.Traversal (defaultMonoidalVisitor, foldMapModule)
14+import PureScript.CST.Types (AppSpine(..), Expr(..), Ident(..), Module, QualifiedName(..), Wrapped(..))
15+
16+-- | Rule: when (not p) x -> unless p x
17+-- | Also: unless (not p) x -> when p x
18+useGuardRule :: Rule
19+useGuardRule = mkRule (RuleId "UseGuard") run
20+ where
21+ run :: RuleContext -> Module Void -> Array LintWarning
22+ run ctx = foldMapModule visitor
23+ where
24+ imports = ctx.imports
25+ visitor = defaultMonoidalVisitor { onExpr = checkExpr imports }
26+
27+ checkExpr :: ImportInfo -> Expr Void -> Array LintWarning
28+ checkExpr imports expr = case expr of
29+ -- Match: if cond then pure unit else x (should use unless)
30+ -- Match: if cond then x else pure unit (should use when)
31+ ExprIf ifExpr ->
32+ checkIfForGuard imports expr ifExpr
33+ _ -> []
34+
35+ checkIfForGuard :: ImportInfo -> Expr Void -> _ -> Array LintWarning
36+ checkIfForGuard imports fullExpr ifExpr =
37+ let
38+ condExpr = ifExpr.cond
39+ thenExpr = unwrapParens ifExpr.true
40+ elseExpr = unwrapParens ifExpr.false
41+ cond = printExpr condExpr
42+ in
43+ if isPureUnit imports thenExpr && hasValue imports "when" then
44+ -- if cond then pure unit else x -> unless cond x
45+ let
46+ action = printExpr elseExpr
47+ replacement = "unless " <> cond <> " " <> action
48+ in
49+ [ LintWarning
50+ { ruleId: RuleId "UseGuard"
51+ , message: WarningMessage "Use unless instead of if-then-pure unit-else"
52+ , range: rangeOf fullExpr
53+ , severity: Hint
54+ , suggestion: Just $ Suggestion
55+ { replacement: ReplacementText replacement
56+ , description: SuggestionDescription "if cond then pure unit else x can be replaced with unless cond x"
57+ }
58+ }
59+ ]
60+ else if isPureUnit imports elseExpr && hasValue imports "when" then
61+ -- if cond then x else pure unit -> when cond x
62+ let
63+ action = printExpr thenExpr
64+ replacement = "when " <> cond <> " " <> action
65+ in
66+ [ LintWarning
67+ { ruleId: RuleId "UseGuard"
68+ , message: WarningMessage "Use when instead of if-then-else-pure unit"
69+ , range: rangeOf fullExpr
70+ , severity: Hint
71+ , suggestion: Just $ Suggestion
72+ { replacement: ReplacementText replacement
73+ , description: SuggestionDescription "if cond then x else pure unit can be replaced with when cond x"
74+ }
75+ }
76+ ]
77+ else
78+ []
79+
80+ unwrapParens :: Expr Void -> Expr Void
81+ unwrapParens (ExprParens (Wrapped { value })) = unwrapParens value
82+ unwrapParens e = e
83+
84+ -- Check if expression is exactly `pure unit`
85+ isPureUnit :: ImportInfo -> Expr Void -> Boolean
86+ isPureUnit imports (ExprApp fnExpr args) =
87+ case fnExpr of
88+ ExprIdent (QualifiedName { name: Ident name }) | name == "pure" && hasValue imports "pure" ->
89+ -- Check that the argument is `unit`
90+ case NEA.head args of
91+ AppTerm argExpr -> isUnit (unwrapParens argExpr)
92+ _ -> false
93+ _ -> false
94+ isPureUnit _ _ = false
95+
96+ -- Check if expression is `unit`
97+ isUnit :: Expr Void -> Boolean
98+ isUnit (ExprIdent (QualifiedName { name: Ident name })) = name == "unit"
99+ isUnit _ = false
lib/purelint/src/Purelint/Rules/UseTraverse.pursnew file+125
@@ -0,0 +1,125 @@
1+module Purelint.Rules.UseTraverse where
2+
3+import Prelude
4+
5+import Data.Array as Array
6+import Data.Array.NonEmpty as NEA
7+import Data.Maybe (Maybe(..))
8+import Data.Tuple (Tuple(..))
9+import Purelint.Imports (ImportInfo, hasValue, hasOp)
10+import Purelint.Print (printExpr)
11+import Purelint.Rule (Rule, RuleContext, mkRule)
12+import Purelint.Types (LintWarning(..), RuleId(..), Severity(..), Suggestion(..), SuggestionDescription(..), ReplacementText(..), WarningMessage(..))
13+import PureScript.CST.Range (rangeOf)
14+import PureScript.CST.Traversal (defaultMonoidalVisitor, foldMapModule)
15+import PureScript.CST.Types (AppSpine(..), Expr(..), Ident(..), Module, Operator(..), QualifiedName(..), Wrapped(..))
16+import Data.Void (Void)
17+
18+-- | Rule: sequenceA (map f x) -> traverse f x
19+-- | Also handles: sequence (map f x) -> mapM f x (for Monads)
20+useTraverseRule :: Rule
21+useTraverseRule = mkRule (RuleId "UseTraverse") run
22+ where
23+ run :: RuleContext -> Module Void -> Array LintWarning
24+ run ctx = foldMapModule visitor
25+ where
26+ imports = ctx.imports
27+ visitor = defaultMonoidalVisitor { onExpr = checkExpr imports }
28+
29+ checkExpr :: ImportInfo -> Expr Void -> Array LintWarning
30+ checkExpr imports expr = case expr of
31+ -- Match: sequenceA (map f x) or sequenceA (f <$> x) or sequenceA (fmap f x)
32+ ExprApp fnExpr args ->
33+ case fnExpr of
34+ ExprIdent qn | isSequenceA imports qn || isSequence imports qn ->
35+ case NEA.head args of
36+ AppTerm innerExpr -> checkInnerArg imports expr qn (unwrapParens innerExpr)
37+ _ -> []
38+ _ -> []
39+ _ -> []
40+
41+ -- | Unwrap parentheses to get the inner expression
42+ unwrapParens :: Expr Void -> Expr Void
43+ unwrapParens (ExprParens (Wrapped { value })) = unwrapParens value
44+ unwrapParens e = e
45+
46+ checkInnerArg :: ImportInfo -> Expr Void -> QualifiedName Ident -> Expr Void -> Array LintWarning
47+ checkInnerArg imports fullExpr seqQn innerExpr =
48+ case innerExpr of
49+ -- Match: map f x
50+ ExprApp mapFn mapArgs | isMapLikeApp imports mapFn ->
51+ case NEA.toArray mapArgs of
52+ [AppTerm fExpr, AppTerm xExpr] ->
53+ mkWarning fullExpr seqQn (printExpr fExpr) (printExpr xExpr)
54+ _ -> mkWarningTemplate fullExpr seqQn
55+ -- Match: f <$> x (operator form)
56+ ExprOp lhs ops | hasMapOperator imports ops ->
57+ case NEA.toArray ops of
58+ [Tuple _ rhs] ->
59+ mkWarning fullExpr seqQn (printExpr lhs) (printExpr rhs)
60+ _ -> mkWarningTemplate fullExpr seqQn
61+ _ -> []
62+
63+ mkWarning :: Expr Void -> QualifiedName Ident -> String -> String -> Array LintWarning
64+ mkWarning fullExpr seqQn fText xText =
65+ let
66+ range = rangeOf fullExpr
67+ seqName = getIdentName seqQn
68+ replacement = if seqName == "sequenceA" || seqName == "sequenceA_" then "traverse" else "mapM"
69+ replacementSuffix = if seqName == "sequenceA_" || seqName == "sequence_" then "_" else ""
70+ concreteReplacement = replacement <> replacementSuffix <> " " <> fText <> " " <> xText
71+ in
72+ [ LintWarning
73+ { ruleId: RuleId "UseTraverse"
74+ , message: WarningMessage $ "Use " <> replacement <> replacementSuffix <> " instead of " <> seqName <> " composed with map/fmap/<$>"
75+ , range
76+ , severity: Warning
77+ , suggestion: Just $ Suggestion
78+ { replacement: ReplacementText concreteReplacement
79+ , description: SuggestionDescription $ seqName <> " (map f x) can be replaced with " <> replacement <> replacementSuffix <> " f x"
80+ }
81+ }
82+ ]
83+
84+ mkWarningTemplate :: Expr Void -> QualifiedName Ident -> Array LintWarning
85+ mkWarningTemplate fullExpr seqQn =
86+ let
87+ range = rangeOf fullExpr
88+ seqName = getIdentName seqQn
89+ replacement = if seqName == "sequenceA" || seqName == "sequenceA_" then "traverse" else "mapM"
90+ replacementSuffix = if seqName == "sequenceA_" || seqName == "sequence_" then "_" else ""
91+ in
92+ [ LintWarning
93+ { ruleId: RuleId "UseTraverse"
94+ , message: WarningMessage $ "Use " <> replacement <> replacementSuffix <> " instead of " <> seqName <> " composed with map/fmap/<$>"
95+ , range
96+ , severity: Warning
97+ , suggestion: Nothing -- No auto-fix for complex patterns
98+ }
99+ ]
100+
101+ isMapLikeApp :: ImportInfo -> Expr Void -> Boolean
102+ isMapLikeApp imports (ExprIdent qn) = isMapLike imports qn
103+ isMapLikeApp _ _ = false
104+
105+ isMapLike :: ImportInfo -> QualifiedName Ident -> Boolean
106+ isMapLike imports (QualifiedName { name: Ident name }) =
107+ (name == "map" && hasValue imports "map") || (name == "fmap" && hasValue imports "fmap")
108+
109+ hasMapOperator :: ImportInfo -> _ -> Boolean
110+ hasMapOperator imports ops = NEA.any (isMapOp imports) ops
111+ where
112+ isMapOp :: ImportInfo -> Tuple (QualifiedName Operator) (Expr Void) -> Boolean
113+ isMapOp imps (Tuple (QualifiedName { name: Operator opName }) _) =
114+ (opName == "<$>" || opName == "<&>") && hasOp imps "<$>"
115+
116+ isSequenceA :: ImportInfo -> QualifiedName Ident -> Boolean
117+ isSequenceA imports (QualifiedName { name: Ident name }) =
118+ (name == "sequenceA" || name == "sequenceA_") && hasValue imports "sequenceA"
119+
120+ isSequence :: ImportInfo -> QualifiedName Ident -> Boolean
121+ isSequence imports (QualifiedName { name: Ident name }) =
122+ (name == "sequence" || name == "sequence_") && hasValue imports "sequence"
123+
124+ getIdentName :: QualifiedName Ident -> String
125+ getIdentName (QualifiedName { name: Ident name }) = name
lib/purelint/src/Purelint/Runner.pursnew file+45
@@ -0,0 +1,45 @@
1+module Purelint.Runner where
2+
3+import Prelude
4+
5+import Data.Array as Array
6+import Data.Either (Either(..))
7+import Data.Newtype (unwrap)
8+import Purelint.Imports (getImportInfo)
9+import Purelint.Rule (Rule, RuleContext)
10+import Purelint.Types (LintResult(..), SourceCode(..))
11+import PureScript.CST (RecoveredParserResult(..), parseModule)
12+import PureScript.CST.Errors (printParseError)
13+import PureScript.CST.Types (Module, ModuleHeader(..), ModuleName)
14+import Unsafe.Coerce (unsafeCoerce)
15+
16+-- | Run a set of rules on source code
17+runRules :: Array Rule -> SourceCode -> Either String LintResult
18+runRules rules (SourceCode source) =
19+ case parseModule source of
20+ ParseSucceeded m -> Right $ runOnModule rules (toVoidModule m)
21+ ParseSucceededWithErrors m _ -> Right $ runOnModule rules (toVoidModule m)
22+ ParseFailed errRec -> Left $ "Parse error at " <> show (errRec.position.line + 1) <> ":" <> show (errRec.position.column + 1) <> ": " <> printParseError errRec.error
23+
24+-- | Run rules on a successfully parsed module
25+runOnModule :: Array Rule -> Module Void -> LintResult
26+runOnModule rules mod =
27+ let
28+ imports = getImportInfo mod
29+ ctx :: RuleContext
30+ ctx = { imports }
31+ warnings = Array.concatMap (\rule -> rule.run ctx mod) rules
32+ modName = getModuleName mod
33+ in
34+ LintResult { warnings, moduleName: modName }
35+
36+-- | Get the module name from a parsed module
37+getModuleName :: forall e. Module e -> ModuleName
38+getModuleName mod =
39+ let (ModuleHeader header) = (unwrap mod).header
40+ in (unwrap header.name).name
41+
42+-- | Convert a recovered module to a Void module (assuming no errors)
43+-- | This is safe after ParseSucceeded because there are no error nodes
44+toVoidModule :: forall e. Module e -> Module Void
45+toVoidModule = unsafeCoerce
lib/purelint/src/Purelint/Types.pursnew file+92
@@ -0,0 +1,92 @@
1+module Purelint.Types where
2+
3+import Prelude
4+
5+import Data.Maybe (Maybe)
6+import Data.Newtype (class Newtype)
7+import PureScript.CST.Types as CST
8+
9+-- | A rule identifier like "UseTraverse" or "EtaReduce"
10+newtype RuleId = RuleId String
11+
12+derive instance Newtype RuleId _
13+derive newtype instance Eq RuleId
14+derive newtype instance Ord RuleId
15+derive newtype instance Show RuleId
16+
17+-- | Severity of a lint warning
18+data Severity
19+ = Hint
20+ | Warning
21+ | Error
22+
23+derive instance Eq Severity
24+derive instance Ord Severity
25+
26+instance Show Severity where
27+ show Hint = "hint"
28+ show Warning = "warning"
29+ show Error = "error"
30+
31+-- | A suggested replacement for the original code
32+newtype Suggestion = Suggestion
33+ { replacement :: ReplacementText
34+ , description :: SuggestionDescription
35+ }
36+
37+derive instance Newtype Suggestion _
38+
39+-- | The text to replace the original code with
40+newtype ReplacementText = ReplacementText String
41+
42+derive instance Newtype ReplacementText _
43+derive newtype instance Eq ReplacementText
44+derive newtype instance Show ReplacementText
45+
46+-- | Human-readable description of what the suggestion does
47+newtype SuggestionDescription = SuggestionDescription String
48+
49+derive instance Newtype SuggestionDescription _
50+derive newtype instance Eq SuggestionDescription
51+derive newtype instance Show SuggestionDescription
52+
53+-- | A message explaining why the lint rule triggered
54+newtype WarningMessage = WarningMessage String
55+
56+derive instance Newtype WarningMessage _
57+derive newtype instance Eq WarningMessage
58+derive newtype instance Show WarningMessage
59+
60+-- | A lint warning produced by a rule
61+newtype LintWarning = LintWarning
62+ { ruleId :: RuleId
63+ , message :: WarningMessage
64+ , range :: CST.SourceRange
65+ , severity :: Severity
66+ , suggestion :: Maybe Suggestion
67+ }
68+
69+derive instance Newtype LintWarning _
70+
71+-- | Result of running all lint rules on a module
72+newtype LintResult = LintResult
73+ { warnings :: Array LintWarning
74+ , moduleName :: CST.ModuleName
75+ }
76+
77+derive instance Newtype LintResult _
78+
79+-- | A file path to lint
80+newtype FilePath = FilePath String
81+
82+derive instance Newtype FilePath _
83+derive newtype instance Eq FilePath
84+derive newtype instance Ord FilePath
85+derive newtype instance Show FilePath
86+
87+-- | Source code content
88+newtype SourceCode = SourceCode String
89+
90+derive instance Newtype SourceCode _
91+derive newtype instance Eq SourceCode
92+derive newtype instance Show SourceCode
purescript-native/ffi_loader.gonew file+30
@@ -0,0 +1,30 @@
1+package ffi_loader
2+
3+// Load PureScript standard library FFI packages. Comment out the ones you don't need for
4+// faster/smaller builds.
5+
6+import (
7+ _ "github.com/i-am-the-slime/go-ffi/purescript-arrays"
8+ _ "github.com/i-am-the-slime/go-ffi/purescript-console"
9+ _ "github.com/i-am-the-slime/go-ffi/purescript-effect"
10+ _ "github.com/i-am-the-slime/go-ffi/purescript-enums"
11+ _ "github.com/i-am-the-slime/go-ffi/purescript-exceptions"
12+ _ "github.com/i-am-the-slime/go-ffi/purescript-foldable-traversable"
13+ _ "github.com/i-am-the-slime/go-ffi/purescript-foreign"
14+ _ "github.com/i-am-the-slime/go-ffi/purescript-foreign-object"
15+ _ "github.com/i-am-the-slime/go-ffi/purescript-functions"
16+ _ "github.com/i-am-the-slime/go-ffi/purescript-integers"
17+ _ "github.com/i-am-the-slime/go-ffi/purescript-lazy"
18+ _ "github.com/i-am-the-slime/go-ffi/purescript-lsp"
19+ _ "github.com/i-am-the-slime/go-ffi/purescript-math"
20+ _ "github.com/i-am-the-slime/go-ffi/purescript-node-fs"
21+ _ "github.com/i-am-the-slime/go-ffi/purescript-node-process"
22+ _ "github.com/i-am-the-slime/go-ffi/purescript-partial"
23+ _ "github.com/i-am-the-slime/go-ffi/purescript-prelude"
24+ _ "github.com/i-am-the-slime/go-ffi/purescript-refs"
25+ _ "github.com/i-am-the-slime/go-ffi/purescript-simple-json"
26+ _ "github.com/i-am-the-slime/go-ffi/purescript-st"
27+ _ "github.com/i-am-the-slime/go-ffi/purescript-strings"
28+ _ "github.com/i-am-the-slime/go-ffi/purescript-unfoldable"
29+ _ "github.com/i-am-the-slime/go-ffi/purescript-unsafe-coerce"
30+)
purescript-native/go.modnew file+12
@@ -0,0 +1,12 @@
1+module project.localhost/purescript-native/ffi-loader
2+
3+go 1.23.2
4+
5+require github.com/i-am-the-slime/go-ffi v0.0.0-20250107191809-8aebdb8aad2e
6+
7+require (
8+ github.com/dlclark/regexp2 v1.4.0 // indirect
9+ github.com/purescript-native/go-runtime v0.1.2 // indirect
10+)
11+
12+replace github.com/i-am-the-slime/go-ffi => /Users/mark/Developer/go-ffi
purescript-native/go.sumnew file+6
@@ -0,0 +1,6 @@
1+github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E=
2+github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
3+github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
4+github.com/purescript-native/go-runtime v0.1.0/go.mod h1:Pl7TR9c9Al8GMlY/R2D7joWjlQuTiB9eBN2a9aXd6GQ=
5+github.com/purescript-native/go-runtime v0.1.2 h1:vxVmLXojzc6nkP29qMxHmEd6iwolF309yP/FskRY88Y=
6+github.com/purescript-native/go-runtime v0.1.2/go.mod h1:Pl7TR9c9Al8GMlY/R2D7joWjlQuTiB9eBN2a9aXd6GQ=
spago.locknew file+1363
@@ -0,0 +1,1363 @@
1+{
2+ "workspace": {
3+ "packages": {
4+ "purelint": {
5+ "path": "lib/purelint",
6+ "core": {
7+ "dependencies": [
8+ "arrays",
9+ "effect",
10+ "either",
11+ "foldable-traversable",
12+ "language-cst-parser",
13+ "lists",
14+ "maybe",
15+ "newtype",
16+ "ordered-collections",
17+ "prelude",
18+ "strings",
19+ "tuples"
20+ ],
21+ "build_plan": [
22+ "arrays",
23+ "bifunctors",
24+ "catenable-lists",
25+ "const",
26+ "contravariant",
27+ "control",
28+ "distributive",
29+ "effect",
30+ "either",
31+ "enums",
32+ "exceptions",
33+ "exists",
34+ "foldable-traversable",
35+ "free",
36+ "functions",
37+ "functors",
38+ "gen",
39+ "identity",
40+ "integers",
41+ "invariant",
42+ "language-cst-parser",
43+ "lazy",
44+ "lists",
45+ "maybe",
46+ "newtype",
47+ "nonempty",
48+ "numbers",
49+ "ordered-collections",
50+ "orders",
51+ "partial",
52+ "prelude",
53+ "profunctor",
54+ "refs",
55+ "safe-coerce",
56+ "st",
57+ "strings",
58+ "tailrec",
59+ "transformers",
60+ "tuples",
61+ "type-equality",
62+ "typelevel-prelude",
63+ "unfoldable",
64+ "unsafe-coerce"
65+ ]
66+ },
67+ "test": {
68+ "dependencies": [],
69+ "build_plan": []
70+ }
71+ },
72+ "purelint-cli": {
73+ "path": "bin/purelint-cli",
74+ "core": {
75+ "dependencies": [
76+ "arrays",
77+ "console",
78+ "effect",
79+ "either",
80+ "foldable-traversable",
81+ "maybe",
82+ "newtype",
83+ "prelude",
84+ "purelint",
85+ "strings"
86+ ],
87+ "build_plan": [
88+ "arrays",
89+ "bifunctors",
90+ "catenable-lists",
91+ "console",
92+ "const",
93+ "contravariant",
94+ "control",
95+ "distributive",
96+ "effect",
97+ "either",
98+ "enums",
99+ "exceptions",
100+ "exists",
101+ "foldable-traversable",
102+ "free",
103+ "functions",
104+ "functors",
105+ "gen",
106+ "identity",
107+ "integers",
108+ "invariant",
109+ "language-cst-parser",
110+ "lazy",
111+ "lists",
112+ "maybe",
113+ "newtype",
114+ "nonempty",
115+ "numbers",
116+ "ordered-collections",
117+ "orders",
118+ "partial",
119+ "prelude",
120+ "profunctor",
121+ "purelint",
122+ "refs",
123+ "safe-coerce",
124+ "st",
125+ "strings",
126+ "tailrec",
127+ "transformers",
128+ "tuples",
129+ "type-equality",
130+ "typelevel-prelude",
131+ "unfoldable",
132+ "unsafe-coerce"
133+ ]
134+ },
135+ "test": {
136+ "dependencies": [],
137+ "build_plan": []
138+ }
139+ },
140+ "purelint-lsp": {
141+ "path": "bin/purelint-lsp",
142+ "core": {
143+ "dependencies": [
144+ "arrays",
145+ "effect",
146+ "either",
147+ "foreign",
148+ "maybe",
149+ "newtype",
150+ "prelude",
151+ "purelint",
152+ "unsafe-coerce"
153+ ],
154+ "build_plan": [
155+ "arrays",
156+ "bifunctors",
157+ "catenable-lists",
158+ "const",
159+ "contravariant",
160+ "control",
161+ "distributive",
162+ "effect",
163+ "either",
164+ "enums",
165+ "exceptions",
166+ "exists",
167+ "foldable-traversable",
168+ "foreign",
169+ "free",
170+ "functions",
171+ "functors",
172+ "gen",
173+ "identity",
174+ "integers",
175+ "invariant",
176+ "language-cst-parser",
177+ "lazy",
178+ "lists",
179+ "maybe",
180+ "newtype",
181+ "nonempty",
182+ "numbers",
183+ "ordered-collections",
184+ "orders",
185+ "partial",
186+ "prelude",
187+ "profunctor",
188+ "purelint",
189+ "refs",
190+ "safe-coerce",
191+ "st",
192+ "strings",
193+ "tailrec",
194+ "transformers",
195+ "tuples",
196+ "type-equality",
197+ "typelevel-prelude",
198+ "unfoldable",
199+ "unsafe-coerce"
200+ ]
201+ },
202+ "test": {
203+ "dependencies": [],
204+ "build_plan": []
205+ }
206+ }
207+ },
208+ "package_set": {
209+ "address": {
210+ "registry": "71.0.0"
211+ },
212+ "compiler": ">=0.15.15 <0.16.0",
213+ "content": {
214+ "abc-parser": "2.1.0",
215+ "ace": "9.1.0",
216+ "address-rfc2821": "0.1.1",
217+ "aff": "8.0.0",
218+ "aff-bus": "6.0.0",
219+ "aff-coroutines": "9.0.0",
220+ "aff-promise": "4.0.0",
221+ "aff-retry": "2.0.0",
222+ "affjax": "13.0.0",
223+ "affjax-node": "1.0.0",
224+ "affjax-web": "1.0.0",
225+ "ansi": "7.0.0",
226+ "apexcharts": "0.5.0",
227+ "applicative-phases": "1.0.0",
228+ "argonaut": "9.0.0",
229+ "argonaut-aeson-generic": "0.4.1",
230+ "argonaut-codecs": "9.1.0",
231+ "argonaut-core": "7.0.0",
232+ "argonaut-generic": "8.0.0",
233+ "argonaut-traversals": "10.0.0",
234+ "argparse-basic": "2.0.0",
235+ "array-builder": "0.1.2",
236+ "array-search": "0.6.0",
237+ "arraybuffer": "13.2.0",
238+ "arraybuffer-builder": "3.1.0",
239+ "arraybuffer-types": "3.0.2",
240+ "arrays": "7.3.0",
241+ "arrays-extra": "0.6.1",
242+ "arrays-zipper": "2.0.1",
243+ "ask": "1.0.0",
244+ "assert": "6.0.0",
245+ "assert-multiple": "0.4.0",
246+ "avar": "5.0.1",
247+ "axon": "0.0.3",
248+ "b64": "0.0.8",
249+ "barbies": "1.0.1",
250+ "barlow-lens": "0.9.0",
251+ "benchlib": "0.0.4",
252+ "bifunctors": "6.1.0",
253+ "bigints": "7.0.1",
254+ "blessed": "1.0.0",
255+ "bolson": "0.3.9",
256+ "bookhound": "0.1.7",
257+ "bower-json": "3.0.0",
258+ "call-by-name": "4.0.1",
259+ "canvas": "6.0.0",
260+ "canvas-action": "9.0.0",
261+ "cartesian": "1.0.6",
262+ "catenable-lists": "7.0.0",
263+ "cbor-stream": "1.3.0",
264+ "chameleon": "1.0.0",
265+ "chameleon-halogen": "1.0.3",
266+ "chameleon-react-basic": "1.1.0",
267+ "chameleon-styled": "2.5.0",
268+ "chameleon-transformers": "1.0.0",
269+ "channel": "1.0.0",
270+ "checked-exceptions": "3.1.1",
271+ "choku": "1.0.2",
272+ "classless": "0.1.1",
273+ "classless-arbitrary": "0.1.1",
274+ "classless-decode-json": "0.1.1",
275+ "classless-encode-json": "0.1.3",
276+ "classnames": "2.0.0",
277+ "codec": "6.1.0",
278+ "codec-argonaut": "10.0.0",
279+ "codec-json": "2.0.0",
280+ "colors": "7.0.1",
281+ "concur-core": "0.5.0",
282+ "concur-react": "0.5.0",
283+ "concurrent-queues": "3.0.0",
284+ "console": "6.1.0",
285+ "const": "6.0.0",
286+ "contravariant": "6.0.0",
287+ "control": "6.0.0",
288+ "convertable-options": "1.0.0",
289+ "coroutines": "7.0.0",
290+ "css": "6.0.0",
291+ "css-class-name-extractor": "0.0.4",
292+ "css-frameworks": "1.0.1",
293+ "csv-stream": "2.3.0",
294+ "data-mvc": "0.0.2",
295+ "datetime": "6.1.0",
296+ "datetime-parsing": "0.2.0",
297+ "debounce": "0.1.0",
298+ "debug": "6.0.2",
299+ "decimals": "7.1.0",
300+ "default-values": "1.0.1",
301+ "deku": "0.9.23",
302+ "deno": "0.0.5",
303+ "dissect": "1.0.0",
304+ "distributive": "6.0.0",
305+ "dodo-printer": "2.2.3",
306+ "dom-filereader": "7.0.0",
307+ "dom-indexed": "13.0.0",
308+ "dom-simple": "0.4.0",
309+ "dotenv": "4.0.3",
310+ "droplet": "0.6.0",
311+ "dts": "1.0.0",
312+ "dual-numbers": "1.0.3",
313+ "dynamic-buffer": "3.0.1",
314+ "echarts-simple": "0.0.1",
315+ "effect": "4.0.0",
316+ "either": "6.1.0",
317+ "elmish": "0.13.0",
318+ "elmish-enzyme": "0.1.1",
319+ "elmish-hooks": "0.11.0",
320+ "elmish-html": "0.10.0",
321+ "elmish-testing-library": "0.3.2",
322+ "elmish-time-machine": "0.4.2",
323+ "email-validate": "7.0.0",
324+ "encoding": "0.0.9",
325+ "enums": "6.0.1",
326+ "env-names": "0.4.0",
327+ "environment": "2.0.0",
328+ "error": "2.0.0",
329+ "eta-conversion": "0.3.2",
330+ "exceptions": "6.1.0",
331+ "exists": "6.0.0",
332+ "exitcodes": "4.0.0",
333+ "expect-inferred": "3.0.0",
334+ "express": "0.9.1",
335+ "ezfetch": "1.1.1",
336+ "fahrtwind": "2.0.0",
337+ "fakerjs": "0.0.1",
338+ "fallback": "0.1.0",
339+ "fast-vect": "1.2.0",
340+ "fetch": "4.1.0",
341+ "fetch-argonaut": "1.0.1",
342+ "fetch-core": "5.1.0",
343+ "fetch-yoga-json": "1.1.0",
344+ "ffi-simple": "0.5.1",
345+ "fft": "0.3.0",
346+ "fft-js": "0.1.0",
347+ "filterable": "5.0.0",
348+ "fix-functor": "0.1.0",
349+ "fixed-points": "7.0.0",
350+ "fixed-precision": "5.0.0",
351+ "flame": "1.6.0",
352+ "float32": "2.0.0",
353+ "fmt": "0.2.1",
354+ "foldable-traversable": "6.0.0",
355+ "foldable-traversable-extra": "0.0.6",
356+ "foreign": "7.0.0",
357+ "foreign-object": "4.1.0",
358+ "foreign-readwrite": "3.4.0",
359+ "forgetmenot": "0.1.0",
360+ "fork": "6.0.0",
361+ "form-urlencoded": "7.0.0",
362+ "formatters": "7.0.0",
363+ "framer-motion": "1.0.1",
364+ "free": "7.1.0",
365+ "freeap": "7.0.0",
366+ "freer-free": "0.0.1",
367+ "freet": "7.0.0",
368+ "functions": "6.0.0",
369+ "functor1": "3.0.0",
370+ "functors": "5.0.0",
371+ "fuzzy": "0.4.0",
372+ "gen": "4.0.0",
373+ "generate-values": "1.0.1",
374+ "generic-router": "0.0.1",
375+ "geojson": "0.0.5",
376+ "geometria": "2.2.0",
377+ "gesso": "1.0.1",
378+ "gojs": "0.1.1",
379+ "golem-fetch": "0.1.0",
380+ "grain": "3.0.0",
381+ "grain-router": "3.0.0",
382+ "grain-virtualized": "3.0.0",
383+ "graphql-client": "10.1.1",
384+ "graphs": "8.1.0",
385+ "group": "4.1.1",
386+ "halogen": "7.0.0",
387+ "halogen-bootstrap5": "5.3.2",
388+ "halogen-canvas": "1.0.0",
389+ "halogen-css": "10.0.0",
390+ "halogen-declarative-canvas": "0.0.8",
391+ "halogen-echarts-simple": "0.0.4",
392+ "halogen-formless": "4.0.3",
393+ "halogen-helix": "1.1.0",
394+ "halogen-hooks": "0.6.3",
395+ "halogen-hooks-extra": "0.9.0",
396+ "halogen-infinite-scroll": "1.1.0",
397+ "halogen-store": "0.5.4",
398+ "halogen-storybook": "2.0.0",
399+ "halogen-subscriptions": "2.0.0",
400+ "halogen-svg-elems": "8.0.0",
401+ "halogen-typewriter": "1.0.4",
402+ "halogen-use-trigger-hooks": "1.0.0",
403+ "halogen-vdom": "8.0.0",
404+ "halogen-vdom-string-renderer": "0.5.0",
405+ "halogen-xterm": "2.0.0",
406+ "heckin": "2.0.1",
407+ "heterogeneous": "0.7.0",
408+ "homogeneous": "0.4.0",
409+ "http-methods": "6.0.0",
410+ "httpurple": "4.0.0",
411+ "huffman": "0.4.0",
412+ "humdrum": "0.0.1",
413+ "hyrule": "2.3.8",
414+ "identity": "6.0.0",
415+ "identy": "4.0.1",
416+ "indexed-db": "1.0.0",
417+ "indexed-monad": "3.0.0",
418+ "ink": "0.0.1",
419+ "int64": "3.0.0",
420+ "integers": "6.0.0",
421+ "interpolate": "5.0.2",
422+ "intersection-observer": "1.0.1",
423+ "invariant": "6.0.0",
424+ "jarilo": "1.0.1",
425+ "jelly": "0.10.0",
426+ "jelly-router": "0.3.0",
427+ "jelly-signal": "0.4.0",
428+ "jest": "1.0.0",
429+ "js-abort-controller": "1.0.0",
430+ "js-bigints": "2.2.1",
431+ "js-date": "8.0.0",
432+ "js-fetch": "0.2.1",
433+ "js-fileio": "3.0.0",
434+ "js-intl": "1.1.4",
435+ "js-iterators": "0.1.1",
436+ "js-maps": "0.1.2",
437+ "js-promise": "1.0.0",
438+ "js-promise-aff": "1.0.0",
439+ "js-timers": "6.1.0",
440+ "js-uri": "3.1.0",
441+ "jsdom": "1.0.0",
442+ "json": "1.1.0",
443+ "json-codecs": "5.0.0",
444+ "justifill": "0.5.0",
445+ "jwt": "0.0.9",
446+ "labeled-data": "0.2.0",
447+ "language-cst-parser": "0.14.1",
448+ "lazy": "6.0.0",
449+ "lazy-joe": "1.0.0",
450+ "lcg": "4.0.0",
451+ "leibniz": "5.0.0",
452+ "leveldb": "1.0.1",
453+ "liminal": "1.0.1",
454+ "linalg": "6.0.0",
455+ "lists": "7.0.0",
456+ "literals": "1.0.2",
457+ "logging": "3.0.0",
458+ "logging-journald": "0.4.0",
459+ "lumi-components": "18.0.0",
460+ "machines": "7.0.0",
461+ "maps-eager": "0.5.0",
462+ "marionette": "1.0.0",
463+ "marionette-react-basic-hooks": "0.1.1",
464+ "marked": "0.1.0",
465+ "matrices": "5.0.1",
466+ "matryoshka": "1.0.0",
467+ "maybe": "6.0.0",
468+ "media-types": "6.0.0",
469+ "meowclient": "1.0.0",
470+ "midi": "4.0.0",
471+ "milkis": "9.0.0",
472+ "mimetype": "0.0.1",
473+ "minibench": "4.0.1",
474+ "mmorph": "7.0.0",
475+ "monad-control": "5.0.0",
476+ "monad-logger": "1.3.1",
477+ "monad-loops": "0.5.0",
478+ "monad-unlift": "1.0.1",
479+ "monoid-extras": "0.0.1",
480+ "monoidal": "0.16.0",
481+ "morello": "0.4.0",
482+ "mote": "3.0.0",
483+ "motsunabe": "2.0.0",
484+ "mvc": "0.0.1",
485+ "mysql": "6.0.1",
486+ "n3": "0.1.0",
487+ "nano-id": "1.1.0",
488+ "nanoid": "0.1.0",
489+ "naturals": "3.0.0",
490+ "nested-functor": "0.2.1",
491+ "newtype": "5.0.0",
492+ "nextjs": "0.1.1",
493+ "nextui": "0.2.0",
494+ "node-buffer": "9.0.0",
495+ "node-child-process": "11.1.0",
496+ "node-event-emitter": "3.0.0",
497+ "node-execa": "5.0.0",
498+ "node-fs": "9.2.0",
499+ "node-glob-basic": "2.0.0",
500+ "node-http": "9.1.0",
501+ "node-http2": "1.1.1",
502+ "node-human-signals": "1.0.0",
503+ "node-net": "5.1.0",
504+ "node-os": "5.1.0",
505+ "node-path": "5.0.1",
506+ "node-process": "11.2.0",
507+ "node-readline": "8.1.1",
508+ "node-sqlite3": "8.0.0",
509+ "node-stream-pipes": "2.1.6",
510+ "node-streams": "9.0.1",
511+ "node-tls": "0.3.1",
512+ "node-url": "7.0.1",
513+ "node-workerbees": "0.3.1",
514+ "node-zlib": "0.4.0",
515+ "nonempty": "7.0.0",
516+ "now": "6.0.0",
517+ "npm-package-json": "2.0.0",
518+ "nullable": "6.0.0",
519+ "numberfield": "0.2.2",
520+ "numbers": "9.0.1",
521+ "oak": "3.1.1",
522+ "oak-debug": "1.2.2",
523+ "object-maps": "0.3.0",
524+ "ocarina": "1.5.4",
525+ "oooooooooorrrrrrrmm-lib": "0.0.1",
526+ "open-colors-scales-and-schemes": "1.0.0",
527+ "open-drawing": "6.0.4",
528+ "open-folds": "6.4.0",
529+ "open-foreign-generic": "11.0.3",
530+ "open-memoize": "6.2.0",
531+ "open-mkdirp-aff": "1.2.0",
532+ "open-pairing": "6.2.0",
533+ "open-smolder": "12.0.2",
534+ "options": "7.0.0",
535+ "optparse": "5.0.1",
536+ "ordered-collections": "3.2.0",
537+ "ordered-set": "0.4.0",
538+ "orders": "6.0.0",
539+ "org-doc": "0.1.0",
540+ "owoify": "1.2.0",
541+ "pairs": "9.0.1",
542+ "parallel": "7.0.0",
543+ "parsing": "11.0.0",
544+ "parsing-dataview": "3.2.4",
545+ "partial": "4.0.0",
546+ "pathy": "9.0.0",
547+ "pha": "0.13.0",
548+ "phaser": "0.7.0",
549+ "phylio": "1.1.2",
550+ "pipes": "8.0.0",
551+ "pirates-charm": "0.0.1",
552+ "play": "1.0.0",
553+ "pmock": "0.9.0",
554+ "point-free": "1.0.0",
555+ "pointed-list": "0.5.1",
556+ "polymorphic-vectors": "4.0.0",
557+ "posix-types": "6.0.0",
558+ "postgresql": "2.0.20",
559+ "precise": "6.0.0",
560+ "precise-datetime": "7.0.0",
561+ "prelude": "6.0.2",
562+ "prettier-printer": "3.0.0",
563+ "printf": "0.1.0",
564+ "priority-queue": "0.1.2",
565+ "profunctor": "6.0.1",
566+ "profunctor-lenses": "8.0.0",
567+ "protobuf": "4.4.0",
568+ "psa-utils": "8.0.0",
569+ "psci-support": "6.0.0",
570+ "punycode": "1.0.0",
571+ "pursfmt": "0.17.0",
572+ "qualified-do": "2.2.0",
573+ "quantities": "12.2.0",
574+ "quickcheck": "8.0.1",
575+ "quickcheck-combinators": "0.1.3",
576+ "quickcheck-laws": "7.0.0",
577+ "quickcheck-utf8": "0.0.0",
578+ "random": "6.0.0",
579+ "rationals": "6.0.0",
580+ "rdf": "0.1.0",
581+ "react": "11.0.0",
582+ "react-aria": "0.2.0",
583+ "react-basic": "17.0.0",
584+ "react-basic-classic": "3.0.0",
585+ "react-basic-dnd": "10.1.0",
586+ "react-basic-dom": "7.0.0",
587+ "react-basic-dom-beta": "0.1.1",
588+ "react-basic-emotion": "7.1.0",
589+ "react-basic-hooks": "8.2.0",
590+ "react-basic-storybook": "2.0.0",
591+ "react-dom": "8.0.0",
592+ "react-halo": "3.0.0",
593+ "react-icons": "1.1.5",
594+ "react-markdown": "0.1.0",
595+ "react-testing-library": "4.0.1",
596+ "react-virtuoso": "1.0.0",
597+ "reactix": "0.6.1",
598+ "read": "1.0.1",
599+ "recharts": "1.1.0",
600+ "record": "4.0.0",
601+ "record-extra": "5.0.1",
602+ "record-extra-srghma": "0.2.8",
603+ "record-ptional-fields": "0.1.2",
604+ "record-studio": "1.0.4",
605+ "refs": "6.0.0",
606+ "remotedata": "5.0.1",
607+ "repr": "0.5.0",
608+ "resize-arrays": "0.0.1",
609+ "resize-observer": "1.0.0",
610+ "resource": "2.0.1",
611+ "resourcet": "1.0.0",
612+ "result": "1.0.3",
613+ "return": "0.2.0",
614+ "ring-modules": "5.0.1",
615+ "rito": "0.3.4",
616+ "roman": "0.4.0",
617+ "rough-notation": "1.0.2",
618+ "routing": "11.0.0",
619+ "routing-duplex": "0.7.0",
620+ "run": "5.0.0",
621+ "safe-coerce": "2.0.0",
622+ "safely": "4.0.1",
623+ "school-of-music": "1.3.0",
624+ "selection-foldable": "0.2.0",
625+ "selective-functors": "1.0.1",
626+ "semirings": "7.0.0",
627+ "shuffle": "2.0.0",
628+ "signal": "13.0.0",
629+ "simple-emitter": "3.0.1",
630+ "simple-i18n": "2.0.1",
631+ "simple-json": "9.0.0",
632+ "simple-json-generics": "0.2.1",
633+ "simple-ulid": "3.0.0",
634+ "sized-matrices": "1.0.0",
635+ "sized-vectors": "5.0.2",
636+ "slug": "3.1.0",
637+ "small-ffi": "4.0.1",
638+ "soundfonts": "4.1.0",
639+ "sparse-matrices": "2.0.1",
640+ "sparse-polynomials": "3.0.1",
641+ "spec": "8.1.1",
642+ "spec-discovery": "8.4.0",
643+ "spec-mocha": "5.1.1",
644+ "spec-node": "0.0.3",
645+ "spec-quickcheck": "5.0.2",
646+ "spec-reporter-xunit": "0.7.1",
647+ "splitmix": "2.1.0",
648+ "ssrs": "1.0.0",
649+ "st": "6.2.0",
650+ "statistics": "0.3.2",
651+ "strictlypositiveint": "1.0.1",
652+ "string-parsers": "8.0.0",
653+ "strings": "6.0.1",
654+ "strings-extra": "4.0.0",
655+ "stringutils": "0.0.12",
656+ "structured-logging": "1.0.0",
657+ "substitute": "0.2.3",
658+ "supply": "0.2.0",
659+ "svg-parser": "3.0.0",
660+ "systemd-journald": "0.3.0",
661+ "tagged": "4.0.2",
662+ "tailrec": "6.1.0",
663+ "tanstack-query": "2.0.0",
664+ "tecton": "0.2.1",
665+ "tecton-halogen": "0.2.0",
666+ "test-unit": "17.0.0",
667+ "text-formatting": "0.1.0",
668+ "thermite": "6.3.1",
669+ "thermite-dom": "0.3.1",
670+ "these": "6.0.0",
671+ "threading": "0.0.3",
672+ "tidy": "0.11.1",
673+ "tidy-codegen": "4.0.1",
674+ "tldr": "0.0.0",
675+ "toestand": "0.9.0",
676+ "transformation-matrix": "1.0.1",
677+ "transformers": "6.1.0",
678+ "transit": "1.0.0",
679+ "tree-rose": "4.0.2",
680+ "trivial-unfold": "0.5.0",
681+ "ts-bridge": "4.0.0",
682+ "tuples": "7.0.0",
683+ "two-or-more": "1.0.0",
684+ "type-equality": "4.0.1",
685+ "typedenv": "2.0.1",
686+ "typelevel": "6.0.0",
687+ "typelevel-lists": "2.1.0",
688+ "typelevel-peano": "1.0.1",
689+ "typelevel-prelude": "7.0.0",
690+ "typelevel-regex": "0.0.3",
691+ "typelevel-rows": "0.1.0",
692+ "typisch": "0.4.0",
693+ "uint": "7.0.0",
694+ "ulid": "3.0.1",
695+ "uncurried-transformers": "1.1.0",
696+ "undefined": "2.0.0",
697+ "undefined-is-not-a-problem": "1.1.0",
698+ "unfoldable": "6.0.0",
699+ "unicode": "6.0.0",
700+ "unique": "0.6.1",
701+ "unlift": "1.0.1",
702+ "unordered-collections": "3.1.0",
703+ "unsafe-coerce": "6.0.0",
704+ "unsafe-reference": "5.0.0",
705+ "untagged-to-tagged": "0.1.4",
706+ "untagged-union": "1.0.0",
707+ "uri": "9.0.0",
708+ "url-immutable": "1.0.0",
709+ "url-regex-safe": "0.1.1",
710+ "uuid": "9.0.0",
711+ "uuidv4": "1.0.0",
712+ "validation": "6.0.0",
713+ "variant": "8.0.0",
714+ "variant-encodings": "2.0.0",
715+ "variant-gen": "1.0.0",
716+ "vectorfield": "1.0.1",
717+ "vectors": "2.1.0",
718+ "versions": "7.0.0",
719+ "visx": "0.0.2",
720+ "vitest": "1.0.0",
721+ "web-clipboard": "6.0.0",
722+ "web-cssom": "2.0.0",
723+ "web-cssom-view": "0.1.0",
724+ "web-dom": "6.0.0",
725+ "web-dom-parser": "8.0.0",
726+ "web-dom-xpath": "3.0.0",
727+ "web-encoding": "3.0.0",
728+ "web-events": "4.0.0",
729+ "web-fetch": "4.0.1",
730+ "web-file": "4.0.0",
731+ "web-geometry": "0.1.0",
732+ "web-html": "4.1.1",
733+ "web-pointerevents": "2.0.0",
734+ "web-proletarian": "1.0.0",
735+ "web-promise": "3.2.0",
736+ "web-resize-observer": "2.1.0",
737+ "web-router": "1.0.0",
738+ "web-socket": "4.0.0",
739+ "web-storage": "5.0.0",
740+ "web-streams": "4.0.0",
741+ "web-touchevents": "4.0.0",
742+ "web-uievents": "5.0.0",
743+ "web-url": "2.0.0",
744+ "web-workers": "1.1.0",
745+ "web-xhr": "5.0.1",
746+ "webb-aff-list": "0.0.2",
747+ "webb-array": "0.0.2",
748+ "webb-channel": "0.0.3",
749+ "webb-commandline": "0.0.3",
750+ "webb-directory": "0.0.7",
751+ "webb-file": "0.0.1",
752+ "webb-map": "0.0.1",
753+ "webb-monad": "0.0.2",
754+ "webb-mutex": "0.0.7",
755+ "webb-parsing": "0.0.5",
756+ "webb-random": "0.0.1",
757+ "webb-refer": "0.0.3",
758+ "webb-set": "0.0.1",
759+ "webb-slot": "0.0.1",
760+ "webb-stateful": "0.0.3",
761+ "webb-string": "0.0.1",
762+ "webb-test": "0.0.1",
763+ "webb-thread": "0.0.2",
764+ "webb-writer": "0.0.3",
765+ "webextension-polyfill": "0.1.0",
766+ "webgpu": "0.0.1",
767+ "which": "2.0.0",
768+ "whine-core": "0.0.32",
769+ "xterm": "1.0.0",
770+ "yaml-next": "3.1.1",
771+ "yoga-fetch": "1.0.1",
772+ "yoga-json": "5.1.0",
773+ "yoga-om": "0.1.0",
774+ "yoga-postgres": "6.0.0",
775+ "yoga-react-dom": "1.0.1",
776+ "yoga-subtlecrypto": "0.1.0",
777+ "yoga-tree": "1.0.0",
778+ "yoga-tree-svg": "0.1.0",
779+ "yoga-tree-utils": "1.0.0",
780+ "z3": "0.0.2",
781+ "zipperarray": "2.0.0"
782+ }
783+ },
784+ "extra_packages": {}
785+ },
786+ "packages": {
787+ "arrays": {
788+ "type": "registry",
789+ "version": "7.3.0",
790+ "integrity": "sha256-tmcklBlc/muUtUfr9RapdCPwnlQeB3aSrC4dK85gQlc=",
791+ "dependencies": [
792+ "bifunctors",
793+ "control",
794+ "foldable-traversable",
795+ "functions",
796+ "maybe",
797+ "nonempty",
798+ "partial",
799+ "prelude",
800+ "safe-coerce",
801+ "st",
802+ "tailrec",
803+ "tuples",
804+ "unfoldable",
805+ "unsafe-coerce"
806+ ]
807+ },
808+ "bifunctors": {
809+ "type": "registry",
810+ "version": "6.1.0",
811+ "integrity": "sha256-6enQzl1vqnFTQZ1WX9BnoOOVdPGO9WZvVXldHckVQvY=",
812+ "dependencies": [
813+ "const",
814+ "either",
815+ "newtype",
816+ "prelude",
817+ "tuples"
818+ ]
819+ },
820+ "catenable-lists": {
821+ "type": "registry",
822+ "version": "7.0.0",
823+ "integrity": "sha256-76vYENhwF4BWTBsjeLuErCH2jqVT4M3R1HX+4RwSftA=",
824+ "dependencies": [
825+ "control",
826+ "foldable-traversable",
827+ "lists",
828+ "maybe",
829+ "prelude",
830+ "tuples",
831+ "unfoldable"
832+ ]
833+ },
834+ "console": {
835+ "type": "registry",
836+ "version": "6.1.0",
837+ "integrity": "sha256-CxmAzjgyuGDmt9FZW51VhV6rBPwR6o0YeKUzA9rSzcM=",
838+ "dependencies": [
839+ "effect",
840+ "prelude"
841+ ]
842+ },
843+ "const": {
844+ "type": "registry",
845+ "version": "6.0.0",
846+ "integrity": "sha256-tNrxDW8D8H4jdHE2HiPzpLy08zkzJMmGHdRqt5BQuTc=",
847+ "dependencies": [
848+ "invariant",
849+ "newtype",
850+ "prelude"
851+ ]
852+ },
853+ "contravariant": {
854+ "type": "registry",
855+ "version": "6.0.0",
856+ "integrity": "sha256-TP+ooAp3vvmdjfQsQJSichF5B4BPDHp3wAJoWchip6c=",
857+ "dependencies": [
858+ "const",
859+ "either",
860+ "newtype",
861+ "prelude",
862+ "tuples"
863+ ]
864+ },
865+ "control": {
866+ "type": "registry",
867+ "version": "6.0.0",
868+ "integrity": "sha256-sH7Pg9E96JCPF9PIA6oQ8+BjTyO/BH1ZuE/bOcyj4Jk=",
869+ "dependencies": [
870+ "newtype",
871+ "prelude"
872+ ]
873+ },
874+ "distributive": {
875+ "type": "registry",
876+ "version": "6.0.0",
877+ "integrity": "sha256-HTDdmEnzigMl+02SJB88j+gAXDx9VKsbvR4MJGDPbOQ=",
878+ "dependencies": [
879+ "identity",
880+ "newtype",
881+ "prelude",
882+ "tuples",
883+ "type-equality"
884+ ]
885+ },
886+ "effect": {
887+ "type": "registry",
888+ "version": "4.0.0",
889+ "integrity": "sha256-eBtZu+HZcMa5HilvI6kaDyVX3ji8p0W9MGKy2K4T6+M=",
890+ "dependencies": [
891+ "prelude"
892+ ]
893+ },
894+ "either": {
895+ "type": "registry",
896+ "version": "6.1.0",
897+ "integrity": "sha256-6hgTPisnMWVwQivOu2PKYcH8uqjEOOqDyaDQVUchTpY=",
898+ "dependencies": [
899+ "control",
900+ "invariant",
901+ "maybe",
902+ "prelude"
903+ ]
904+ },
905+ "enums": {
906+ "type": "registry",
907+ "version": "6.0.1",
908+ "integrity": "sha256-HWaD73JFLorc4A6trKIRUeDMdzE+GpkJaEOM1nTNkC8=",
909+ "dependencies": [
910+ "control",
911+ "either",
912+ "gen",
913+ "maybe",
914+ "newtype",
915+ "nonempty",
916+ "partial",
917+ "prelude",
918+ "tuples",
919+ "unfoldable"
920+ ]
921+ },
922+ "exceptions": {
923+ "type": "registry",
924+ "version": "6.1.0",
925+ "integrity": "sha256-K0T89IHtF3vBY7eSAO7eDOqSb2J9kZGAcDN5+IKsF8E=",
926+ "dependencies": [
927+ "effect",
928+ "either",
929+ "maybe",
930+ "prelude"
931+ ]
932+ },
933+ "exists": {
934+ "type": "registry",
935+ "version": "6.0.0",
936+ "integrity": "sha256-A0JQHpTfo1dNOj9U5/Fd3xndlRSE0g2IQWOGor2yXn8=",
937+ "dependencies": [
938+ "unsafe-coerce"
939+ ]
940+ },
941+ "foldable-traversable": {
942+ "type": "registry",
943+ "version": "6.0.0",
944+ "integrity": "sha256-fLeqRYM4jUrZD5H4WqcwUgzU7XfYkzO4zhgtNc3jcWM=",
945+ "dependencies": [
946+ "bifunctors",
947+ "const",
948+ "control",
949+ "either",
950+ "functors",
951+ "identity",
952+ "maybe",
953+ "newtype",
954+ "orders",
955+ "prelude",
956+ "tuples"
957+ ]
958+ },
959+ "foreign": {
960+ "type": "registry",
961+ "version": "7.0.0",
962+ "integrity": "sha256-1ORiqoS3HW+qfwSZAppHPWy4/6AQysxZ2t29jcdUMNA=",
963+ "dependencies": [
964+ "either",
965+ "functions",
966+ "identity",
967+ "integers",
968+ "lists",
969+ "maybe",
970+ "prelude",
971+ "strings",
972+ "transformers"
973+ ]
974+ },
975+ "free": {
976+ "type": "registry",
977+ "version": "7.1.0",
978+ "integrity": "sha256-JAumgEsGSzJCNLD8AaFvuX7CpqS5yruCngi6yI7+V5k=",
979+ "dependencies": [
980+ "catenable-lists",
981+ "control",
982+ "distributive",
983+ "either",
984+ "exists",
985+ "foldable-traversable",
986+ "invariant",
987+ "lazy",
988+ "maybe",
989+ "prelude",
990+ "tailrec",
991+ "transformers",
992+ "tuples",
993+ "unsafe-coerce"
994+ ]
995+ },
996+ "functions": {
997+ "type": "registry",
998+ "version": "6.0.0",
999+ "integrity": "sha256-adMyJNEnhGde2unHHAP79gPtlNjNqzgLB8arEOn9hLI=",
1000+ "dependencies": [
1001+ "prelude"
1002+ ]
1003+ },
1004+ "functors": {
1005+ "type": "registry",
1006+ "version": "5.0.0",
1007+ "integrity": "sha256-zfPWWYisbD84MqwpJSZFlvM6v86McM68ob8p9s27ywU=",
1008+ "dependencies": [
1009+ "bifunctors",
1010+ "const",
1011+ "contravariant",
1012+ "control",
1013+ "distributive",
1014+ "either",
1015+ "invariant",
1016+ "maybe",
1017+ "newtype",
1018+ "prelude",
1019+ "profunctor",
1020+ "tuples",
1021+ "unsafe-coerce"
1022+ ]
1023+ },
1024+ "gen": {
1025+ "type": "registry",
1026+ "version": "4.0.0",
1027+ "integrity": "sha256-f7yzAXWwr+xnaqEOcvyO3ezKdoes8+WXWdXIHDBCAPI=",
1028+ "dependencies": [
1029+ "either",
1030+ "foldable-traversable",
1031+ "identity",
1032+ "maybe",
1033+ "newtype",
1034+ "nonempty",
1035+ "prelude",
1036+ "tailrec",
1037+ "tuples",
1038+ "unfoldable"
1039+ ]
1040+ },
1041+ "identity": {
1042+ "type": "registry",
1043+ "version": "6.0.0",
1044+ "integrity": "sha256-4wY0XZbAksjY6UAg99WkuKyJlQlWAfTi2ssadH0wVMY=",
1045+ "dependencies": [
1046+ "control",
1047+ "invariant",
1048+ "newtype",
1049+ "prelude"
1050+ ]
1051+ },
1052+ "integers": {
1053+ "type": "registry",
1054+ "version": "6.0.0",
1055+ "integrity": "sha256-sf+sK26R1hzwl3NhXR7WAu9zCDjQnfoXwcyGoseX158=",
1056+ "dependencies": [
1057+ "maybe",
1058+ "numbers",
1059+ "prelude"
1060+ ]
1061+ },
1062+ "invariant": {
1063+ "type": "registry",
1064+ "version": "6.0.0",
1065+ "integrity": "sha256-RGWWyYrz0Hs1KjPDA+87Kia67ZFBhfJ5lMGOMCEFoLo=",
1066+ "dependencies": [
1067+ "control",
1068+ "prelude"
1069+ ]
1070+ },
1071+ "language-cst-parser": {
1072+ "type": "registry",
1073+ "version": "0.14.1",
1074+ "integrity": "sha256-LJzh1ZTaKjcrHx95ZfO2La3w6Xb/IZQT3m2Nuj4n1dM=",
1075+ "dependencies": [
1076+ "arrays",
1077+ "const",
1078+ "control",
1079+ "effect",
1080+ "either",
1081+ "enums",
1082+ "foldable-traversable",
1083+ "free",
1084+ "functions",
1085+ "functors",
1086+ "identity",
1087+ "integers",
1088+ "lazy",
1089+ "lists",
1090+ "maybe",
1091+ "newtype",
1092+ "numbers",
1093+ "ordered-collections",
1094+ "partial",
1095+ "prelude",
1096+ "st",
1097+ "strings",
1098+ "transformers",
1099+ "tuples",
1100+ "typelevel-prelude",
1101+ "unfoldable",
1102+ "unsafe-coerce"
1103+ ]
1104+ },
1105+ "lazy": {
1106+ "type": "registry",
1107+ "version": "6.0.0",
1108+ "integrity": "sha256-lMsfFOnlqfe4KzRRiW8ot5ge6HtcU3Eyh2XkXcP5IgU=",
1109+ "dependencies": [
1110+ "control",
1111+ "foldable-traversable",
1112+ "invariant",
1113+ "prelude"
1114+ ]
1115+ },
1116+ "lists": {
1117+ "type": "registry",
1118+ "version": "7.0.0",
1119+ "integrity": "sha256-EKF15qYqucuXP2lT/xPxhqy58f0FFT6KHdIB/yBOayI=",
1120+ "dependencies": [
1121+ "bifunctors",
1122+ "control",
1123+ "foldable-traversable",
1124+ "lazy",
1125+ "maybe",
1126+ "newtype",
1127+ "nonempty",
1128+ "partial",
1129+ "prelude",
1130+ "tailrec",
1131+ "tuples",
1132+ "unfoldable"
1133+ ]
1134+ },
1135+ "maybe": {
1136+ "type": "registry",
1137+ "version": "6.0.0",
1138+ "integrity": "sha256-5cCIb0wPwbat2PRkQhUeZO0jcAmf8jCt2qE0wbC3v2Q=",
1139+ "dependencies": [
1140+ "control",
1141+ "invariant",
1142+ "newtype",
1143+ "prelude"
1144+ ]
1145+ },
1146+ "newtype": {
1147+ "type": "registry",
1148+ "version": "5.0.0",
1149+ "integrity": "sha256-gdrQu8oGe9eZE6L3wOI8ql/igOg+zEGB5ITh2g+uttw=",
1150+ "dependencies": [
1151+ "prelude",
1152+ "safe-coerce"
1153+ ]
1154+ },
1155+ "nonempty": {
1156+ "type": "registry",
1157+ "version": "7.0.0",
1158+ "integrity": "sha256-54ablJZUHGvvlTJzi3oXyPCuvY6zsrWJuH/dMJ/MFLs=",
1159+ "dependencies": [
1160+ "control",
1161+ "foldable-traversable",
1162+ "maybe",
1163+ "prelude",
1164+ "tuples",
1165+ "unfoldable"
1166+ ]
1167+ },
1168+ "numbers": {
1169+ "type": "registry",
1170+ "version": "9.0.1",
1171+ "integrity": "sha256-/9M6aeMDBdB4cwYDeJvLFprAHZ49EbtKQLIJsneXLIk=",
1172+ "dependencies": [
1173+ "functions",
1174+ "maybe"
1175+ ]
1176+ },
1177+ "ordered-collections": {
1178+ "type": "registry",
1179+ "version": "3.2.0",
1180+ "integrity": "sha256-o9jqsj5rpJmMdoe/zyufWHFjYYFTTsJpgcuCnqCO6PM=",
1181+ "dependencies": [
1182+ "arrays",
1183+ "foldable-traversable",
1184+ "gen",
1185+ "lists",
1186+ "maybe",
1187+ "partial",
1188+ "prelude",
1189+ "st",
1190+ "tailrec",
1191+ "tuples",
1192+ "unfoldable"
1193+ ]
1194+ },
1195+ "orders": {
1196+ "type": "registry",
1197+ "version": "6.0.0",
1198+ "integrity": "sha256-nBA0g3/ai0euH8q9pSbGqk53W2q6agm/dECZTHcoink=",
1199+ "dependencies": [
1200+ "newtype",
1201+ "prelude"
1202+ ]
1203+ },
1204+ "partial": {
1205+ "type": "registry",
1206+ "version": "4.0.0",
1207+ "integrity": "sha256-fwXerld6Xw1VkReh8yeQsdtLVrjfGiVuC5bA1Wyo/J4=",
1208+ "dependencies": []
1209+ },
1210+ "prelude": {
1211+ "type": "registry",
1212+ "version": "6.0.2",
1213+ "integrity": "sha256-kiAPZxihtAel8uRiTNdccf4qylp/9J3jNkEHNAD0MsE=",
1214+ "dependencies": []
1215+ },
1216+ "profunctor": {
1217+ "type": "registry",
1218+ "version": "6.0.1",
1219+ "integrity": "sha256-E58hSYdJvF2Qjf9dnWLPlJKh2Z2fLfFLkQoYi16vsFk=",
1220+ "dependencies": [
1221+ "control",
1222+ "distributive",
1223+ "either",
1224+ "exists",
1225+ "invariant",
1226+ "newtype",
1227+ "prelude",
1228+ "tuples"
1229+ ]
1230+ },
1231+ "refs": {
1232+ "type": "registry",
1233+ "version": "6.0.0",
1234+ "integrity": "sha256-Vgwne7jIbD3ZMoLNNETLT8Litw6lIYo3MfYNdtYWj9s=",
1235+ "dependencies": [
1236+ "effect",
1237+ "prelude"
1238+ ]
1239+ },
1240+ "safe-coerce": {
1241+ "type": "registry",
1242+ "version": "2.0.0",
1243+ "integrity": "sha256-a1ibQkiUcbODbLE/WAq7Ttbbh9ex+x33VCQ7GngKudU=",
1244+ "dependencies": [
1245+ "unsafe-coerce"
1246+ ]
1247+ },
1248+ "st": {
1249+ "type": "registry",
1250+ "version": "6.2.0",
1251+ "integrity": "sha256-z9X0WsOUlPwNx9GlCC+YccCyz8MejC8Wb0C4+9fiBRY=",
1252+ "dependencies": [
1253+ "partial",
1254+ "prelude",
1255+ "tailrec",
1256+ "unsafe-coerce"
1257+ ]
1258+ },
1259+ "strings": {
1260+ "type": "registry",
1261+ "version": "6.0.1",
1262+ "integrity": "sha256-WssD3DbX4OPzxSdjvRMX0yvc9+pS7n5gyPv5I2Trb7k=",
1263+ "dependencies": [
1264+ "arrays",
1265+ "control",
1266+ "either",
1267+ "enums",
1268+ "foldable-traversable",
1269+ "gen",
1270+ "integers",
1271+ "maybe",
1272+ "newtype",
1273+ "nonempty",
1274+ "partial",
1275+ "prelude",
1276+ "tailrec",
1277+ "tuples",
1278+ "unfoldable",
1279+ "unsafe-coerce"
1280+ ]
1281+ },
1282+ "tailrec": {
1283+ "type": "registry",
1284+ "version": "6.1.0",
1285+ "integrity": "sha256-Xx19ECVDRrDWpz9D2GxQHHV89vd61dnXxQm0IcYQHGk=",
1286+ "dependencies": [
1287+ "bifunctors",
1288+ "effect",
1289+ "either",
1290+ "identity",
1291+ "maybe",
1292+ "partial",
1293+ "prelude",
1294+ "refs"
1295+ ]
1296+ },
1297+ "transformers": {
1298+ "type": "registry",
1299+ "version": "6.1.0",
1300+ "integrity": "sha256-3Bm+Z6tsC/paG888XkywDngJ2JMos+JfOhRlkVfb7gI=",
1301+ "dependencies": [
1302+ "control",
1303+ "distributive",
1304+ "effect",
1305+ "either",
1306+ "exceptions",
1307+ "foldable-traversable",
1308+ "identity",
1309+ "lazy",
1310+ "maybe",
1311+ "newtype",
1312+ "prelude",
1313+ "st",
1314+ "tailrec",
1315+ "tuples",
1316+ "unfoldable"
1317+ ]
1318+ },
1319+ "tuples": {
1320+ "type": "registry",
1321+ "version": "7.0.0",
1322+ "integrity": "sha256-1rXgTomes9105BjgXqIw0FL6Fz1lqqUTLWOumhWec1M=",
1323+ "dependencies": [
1324+ "control",
1325+ "invariant",
1326+ "prelude"
1327+ ]
1328+ },
1329+ "type-equality": {
1330+ "type": "registry",
1331+ "version": "4.0.1",
1332+ "integrity": "sha256-Hs9D6Y71zFi/b+qu5NSbuadUQXe5iv5iWx0226vOHUw=",
1333+ "dependencies": []
1334+ },
1335+ "typelevel-prelude": {
1336+ "type": "registry",
1337+ "version": "7.0.0",
1338+ "integrity": "sha256-uFF2ph+vHcQpfPuPf2a3ukJDFmLhApmkpTMviHIWgJM=",
1339+ "dependencies": [
1340+ "prelude",
1341+ "type-equality"
1342+ ]
1343+ },
1344+ "unfoldable": {
1345+ "type": "registry",
1346+ "version": "6.0.0",
1347+ "integrity": "sha256-JtikvJdktRap7vr/K4ITlxUX1QexpnqBq0G/InLr6eg=",
1348+ "dependencies": [
1349+ "foldable-traversable",
1350+ "maybe",
1351+ "partial",
1352+ "prelude",
1353+ "tuples"
1354+ ]
1355+ },
1356+ "unsafe-coerce": {
1357+ "type": "registry",
1358+ "version": "6.0.0",
1359+ "integrity": "sha256-IqIYW4Vkevn8sI+6aUwRGvd87tVL36BBeOr0cGAE7t0=",
1360+ "dependencies": []
1361+ }
1362+ }
1363+}
spago.yamlnew file+21
@@ -0,0 +1,21 @@
1+workspace:
2+ packageSet:
3+ registry: 71.0.0
4+ extraPackages: {}
5+ backend:
6+ cmd: psgo
7+
8+package:
9+ name: purelint-tests
10+ dependencies:
11+ - console
12+ - effect
13+ - either
14+ - maybe
15+ - newtype
16+ - prelude
17+ - purelint
18+ - arrays
19+ test:
20+ main: Test.Main
21+ dependencies: []
test/LintExamples.pursnew file+140
@@ -0,0 +1,140 @@
1+module Test.LintExamples where
2+
3+import Prelude
4+
5+import Data.Traversable (class Traversable, sequence)
6+
7+-- ============================================================================
8+-- FmapId: map identity x -> x
9+-- ============================================================================
10+
11+-- | map identity x -> x
12+fmapId1 :: Array Int
13+fmapId1 = map identity [ 1, 2, 3 ]
14+
15+-- | fmap identity x -> x (same as map in PureScript)
16+fmapId2 :: Array Int
17+fmapId2 = map identity [ 1, 2, 3 ]
18+
19+-- | identity <$> x -> x
20+fmapId3 :: Array Int
21+fmapId3 = identity <$> [ 1, 2, 3 ]
22+
23+-- | x <#> identity -> x
24+fmapId4 :: Array Int
25+fmapId4 = [ 1, 2, 3 ] <#> identity
26+
27+-- ============================================================================
28+-- MapFusion: map f (map g x) -> map (f <<< g) x
29+-- ============================================================================
30+
31+-- | map f (map g x) -> map (f <<< g) x
32+mapFusion1 :: Array String
33+mapFusion1 = map show (map (_ + 1) [ 1, 2, 3 ])
34+
35+-- | f <$> (g <$> x) -> (f <<< g) <$> x
36+mapFusion2 :: Array String
37+mapFusion2 = show <$> ((_ + 1) <$> [ 1, 2, 3 ])
38+
39+-- | x <#> g <#> f -> x <#> (g >>> f)
40+mapFusion3 :: Array String
41+mapFusion3 = [ 1, 2, 3 ] <#> (_ + 1) <#> show
42+
43+-- ============================================================================
44+-- UseTraverse: sequence (map f x) -> traverse f x
45+-- ============================================================================
46+
47+-- | sequence (map f x) -> traverse f x
48+useTraverse1 :: forall t f a. Traversable t => Applicative f => (a -> f a) -> t a -> f (t a)
49+useTraverse1 f xs = sequence (map f xs)
50+
51+-- | sequence (f <$> x) -> traverse f x
52+useTraverse2 :: forall t f a. Traversable t => Applicative f => (a -> f a) -> t a -> f (t a)
53+useTraverse2 f xs = sequence (f <$> xs)
54+
55+-- ============================================================================
56+-- ConcatMap: concat (map f x) -> concatMap f x
57+-- ============================================================================
58+
59+-- | join (map f x) -> (=<<) f x (for Monads)
60+concatMap1 :: forall m a. Monad m => (a -> m a) -> m a -> m a
61+concatMap1 f xs = join (map f xs)
62+
63+-- ============================================================================
64+-- NotEqual: not (a == b) -> a /= b
65+-- ============================================================================
66+
67+-- | not (a == b) -> a /= b
68+notEqual1 :: forall a. Eq a => a -> a -> Boolean
69+notEqual1 x y = not (x == y)
70+
71+-- | not (a /= b) -> a == b
72+notEqual2 :: forall a. Eq a => a -> a -> Boolean
73+notEqual2 x y = not (x /= y)
74+
75+-- ============================================================================
76+-- UseGuard: if cond then x else pure unit -> when cond x
77+-- ============================================================================
78+
79+-- | if cond then x else pure unit -> when cond x
80+useGuard1 :: forall m. Applicative m => Boolean -> m Unit -> m Unit
81+useGuard1 cond action = if cond then action else pure unit
82+
83+-- | if cond then pure unit else x -> unless cond x
84+useGuard2 :: forall m. Applicative m => Boolean -> m Unit -> m Unit
85+useGuard2 cond action = if cond then pure unit else action
86+
87+-- ============================================================================
88+-- EtaReduce: \x -> f x -> f
89+-- ============================================================================
90+
91+-- | \x -> f x -> f
92+etaReduce1 :: forall a. Show a => a -> String
93+etaReduce1 = \x -> show x
94+
95+-- | \x -> f (g x) -> f <<< g (nested application)
96+etaReduce2 :: Boolean -> String
97+etaReduce2 = \x -> show (not x)
98+
99+-- ============================================================================
100+-- EtaReduceDecl: foo x = bar x -> foo = bar
101+-- ============================================================================
102+
103+-- | foo x = bar x -> foo = bar
104+etaReduceDecl1 :: forall a. Show a => a -> String
105+etaReduceDecl1 x = show x
106+
107+-- | foo a b = bar a b -> foo a = bar a
108+etaReduceDecl2 :: String -> String -> String
109+etaReduceDecl2 a b = append a b
110+
111+-- ============================================================================
112+-- RedundantBind: x >>= pure -> x
113+-- ============================================================================
114+
115+-- | x >>= pure -> x
116+redundantBind1 :: forall m a. Monad m => m a -> m a
117+redundantBind1 x = x
118+
119+-- | pure x >>= f can be simplified (hint only, no auto-fix)
120+redundantBind2 :: forall m a b. Monad m => a -> (a -> m b) -> m b
121+redundantBind2 x f = pure x >>= f
122+
123+-- ============================================================================
124+-- LetToWhere: let x = y in z -> z where x = y
125+-- ============================================================================
126+
127+-- | let x = y in z -> z where x = y
128+letToWhere1 :: Int -> Int
129+letToWhere1 x = y * 2
130+ where
131+ y = x + 1
132+
133+-- | Multiple let bindings
134+letToWhere2 :: Int -> Int
135+letToWhere2 x =
136+ let
137+ a = x + 1
138+ b = x * 2
139+ in
140+ a + b
test/Test/Main.pursnew file+343
@@ -0,0 +1,343 @@
1+module Test.Main where
2+
3+import Prelude
4+
5+import Data.Array as Array
6+import Data.Either (Either(..))
7+import Data.Foldable (for_)
8+import Data.Maybe (Maybe(..))
9+import Data.Newtype (unwrap)
10+import Effect (Effect)
11+import Effect.Class.Console (log)
12+import Purelint.Fix (applyAllFixes)
13+import Purelint.Rules.FmapId (fmapIdRule)
14+import Purelint.Rules.MapFusion (mapFusionRule)
15+import Purelint.Rules.UseTraverse (useTraverseRule)
16+import Purelint.Rules.ConcatMap (concatMapRule)
17+import Purelint.Rules.NotEqual (notEqualRule)
18+import Purelint.Rules.UseGuard (useGuardRule)
19+import Purelint.Rules.EtaReduce (etaReduceRule)
20+import Purelint.Rules.EtaReduceDecl (etaReduceDeclRule)
21+import Purelint.Rules.RedundantBind (redundantBindRule)
22+import Purelint.Rules.LetToWhere (letToWhereRule)
23+import Purelint.Rule (Rule)
24+import Purelint.Runner (runRules)
25+import Purelint.Types (LintResult(..), LintWarning(..), RuleId(..), SourceCode(..), Suggestion(..))
26+
27+-- | Simple test framework
28+type TestResult = { passed :: Boolean, name :: String, message :: String }
29+
30+runTest :: String -> Boolean -> String -> TestResult
31+runTest name passed message = { passed, name, message }
32+
33+-- | Assert that running rules produces exactly n warnings
34+assertWarningCount :: String -> Array Rule -> String -> Int -> TestResult
35+assertWarningCount name rules source expected =
36+ case runRules rules (SourceCode source) of
37+ Left err -> runTest name false ("Parse error: " <> err)
38+ Right (LintResult result) ->
39+ let actual = Array.length result.warnings
40+ in if actual == expected
41+ then runTest name true "OK"
42+ else runTest name false ("Expected " <> show expected <> " warnings, got " <> show actual)
43+
44+-- | Assert that the first warning has a specific rule ID
45+assertRuleId :: String -> Array Rule -> String -> String -> TestResult
46+assertRuleId name rules source expectedRuleId =
47+ case runRules rules (SourceCode source) of
48+ Left err -> runTest name false ("Parse error: " <> err)
49+ Right (LintResult result) ->
50+ case Array.head result.warnings of
51+ Nothing -> runTest name false "No warnings produced"
52+ Just (LintWarning w) ->
53+ let (RuleId rid) = w.ruleId
54+ in if rid == expectedRuleId
55+ then runTest name true "OK"
56+ else runTest name false ("Expected rule " <> expectedRuleId <> ", got " <> rid)
57+
58+-- | Assert that the first warning has a suggestion with expected replacement
59+assertSuggestion :: String -> Array Rule -> String -> String -> TestResult
60+assertSuggestion name rules source expectedReplacement =
61+ case runRules rules (SourceCode source) of
62+ Left err -> runTest name false ("Parse error: " <> err)
63+ Right (LintResult result) ->
64+ case Array.head result.warnings of
65+ Nothing -> runTest name false "No warnings produced"
66+ Just (LintWarning w) ->
67+ case w.suggestion of
68+ Nothing -> runTest name false "No suggestion in warning"
69+ Just (Suggestion s) ->
70+ let actual = unwrap s.replacement
71+ in if actual == expectedReplacement
72+ then runTest name true "OK"
73+ else runTest name false ("Expected suggestion '" <> expectedReplacement <> "', got '" <> actual <> "'")
74+
75+-- | Assert that applying fixes produces expected output
76+assertFix :: String -> Array Rule -> String -> String -> TestResult
77+assertFix name rules source expectedFixed =
78+ case runRules rules (SourceCode source) of
79+ Left err -> runTest name false ("Parse error: " <> err)
80+ Right result ->
81+ let fixed = applyAllFixes source result
82+ in if fixed == expectedFixed
83+ then runTest name true "OK"
84+ else runTest name false ("Expected:\n" <> expectedFixed <> "\nGot:\n" <> fixed)
85+
86+-- | Run all tests and report results
87+main :: Effect Unit
88+main = do
89+ log "Running purelint tests...\n"
90+
91+ let results = fmapIdTests
92+ <> mapFusionTests
93+ <> useTraverseTests
94+ <> concatMapTests
95+ <> notEqualTests
96+ <> useGuardTests
97+ <> etaReduceTests
98+ <> etaReduceDeclTests
99+ <> redundantBindTests
100+ <> letToWhereTests
101+
102+ let passed = Array.length $ Array.filter _.passed results
103+ let total = Array.length results
104+
105+ -- Report failures
106+ let failures = Array.filter (not <<< _.passed) results
107+ when (Array.length failures > 0) do
108+ log "FAILURES:"
109+ for_ failures (\r -> log (" โœ— " <> r.name <> ": " <> r.message))
110+ log ""
111+
112+ -- Report summary
113+ log $ "Tests: " <> show passed <> "/" <> show total <> " passed"
114+
115+ when (passed < total) do
116+ log "\nSome tests failed!"
117+
118+-- | Helper: wrap code in a module with Prelude import
119+withPrelude :: String -> String
120+withPrelude code = "module Test where\nimport Prelude\n" <> code
121+
122+-- | Helper: wrap code in module without imports
123+withoutImports :: String -> String
124+withoutImports code = "module Test where\n" <> code
125+
126+-- ============================================================================
127+-- FmapId Tests
128+-- ============================================================================
129+
130+fmapIdTests :: Array TestResult
131+fmapIdTests =
132+ -- Positive cases: should detect
133+ [ assertWarningCount "FmapId: map identity x" [fmapIdRule]
134+ (withPrelude "x = map identity [1]") 1
135+ , assertRuleId "FmapId: correct rule id" [fmapIdRule]
136+ (withPrelude "x = map identity [1]") "FmapId"
137+ , assertSuggestion "FmapId: map identity suggestion" [fmapIdRule]
138+ (withPrelude "x = map identity [1]") "[1]"
139+ , assertWarningCount "FmapId: fmap identity x" [fmapIdRule]
140+ (withPrelude "x = fmap identity [1]") 1
141+ , assertWarningCount "FmapId: identity <$> x" [fmapIdRule]
142+ (withPrelude "x = identity <$> [1]") 1
143+ , assertWarningCount "FmapId: x <#> identity" [fmapIdRule]
144+ (withPrelude "x = [1] <#> identity") 1
145+
146+ -- Negative cases: should NOT detect
147+ , assertWarningCount "FmapId: no fire on map show" [fmapIdRule]
148+ (withPrelude "x = map show [1]") 0
149+ , assertWarningCount "FmapId: no fire without import" [fmapIdRule]
150+ (withoutImports "x = map identity [1]") 0
151+
152+ -- Auto-fix test
153+ , assertFix "FmapId: fix map identity" [fmapIdRule]
154+ (withPrelude "x = map identity [1]")
155+ (withPrelude "x = [1]")
156+ ]
157+
158+-- ============================================================================
159+-- MapFusion Tests
160+-- ============================================================================
161+
162+mapFusionTests :: Array TestResult
163+mapFusionTests =
164+ -- Positive cases
165+ [ assertWarningCount "MapFusion: map f (map g x)" [mapFusionRule]
166+ (withPrelude "x = map f (map g [1])") 1
167+ , assertRuleId "MapFusion: correct rule id" [mapFusionRule]
168+ (withPrelude "x = map f (map g [1])") "MapFusion"
169+ , assertWarningCount "MapFusion: f <$> (g <$> x)" [mapFusionRule]
170+ (withPrelude "x = f <$> (g <$> [1])") 1
171+ , assertWarningCount "MapFusion: x <#> g <#> f" [mapFusionRule]
172+ (withPrelude "x = [1] <#> g <#> f") 1
173+
174+ -- Negative cases
175+ , assertWarningCount "MapFusion: no fire on single map" [mapFusionRule]
176+ (withPrelude "x = map f [1]") 0
177+ , assertWarningCount "MapFusion: no fire without import" [mapFusionRule]
178+ (withoutImports "x = map f (map g [1])") 0
179+ ]
180+
181+-- ============================================================================
182+-- UseTraverse Tests
183+-- ============================================================================
184+
185+useTraverseTests :: Array TestResult
186+useTraverseTests =
187+ -- Positive cases
188+ [ assertWarningCount "UseTraverse: sequenceA (map f x)" [useTraverseRule]
189+ (withPrelude "x = sequenceA (map f [1])") 1
190+ , assertRuleId "UseTraverse: correct rule id" [useTraverseRule]
191+ (withPrelude "x = sequenceA (map f [1])") "UseTraverse"
192+ , assertWarningCount "UseTraverse: sequenceA (f <$> x)" [useTraverseRule]
193+ (withPrelude "x = sequenceA (f <$> [1])") 1
194+ , assertWarningCount "UseTraverse: sequence (map f x)" [useTraverseRule]
195+ (withPrelude "x = sequence (map f [1])") 1
196+
197+ -- Negative cases
198+ , assertWarningCount "UseTraverse: no fire on sequenceA alone" [useTraverseRule]
199+ (withPrelude "x = sequenceA [1]") 0
200+ , assertWarningCount "UseTraverse: no fire without import" [useTraverseRule]
201+ (withoutImports "x = sequenceA (map f [1])") 0
202+ ]
203+
204+-- ============================================================================
205+-- ConcatMap Tests
206+-- ============================================================================
207+
208+concatMapTests :: Array TestResult
209+concatMapTests =
210+ -- Positive cases
211+ [ assertWarningCount "ConcatMap: concat (map f x)" [concatMapRule]
212+ (withPrelude "x = concat (map f [1])") 1
213+ , assertRuleId "ConcatMap: correct rule id" [concatMapRule]
214+ (withPrelude "x = concat (map f [1])") "ConcatMap"
215+ , assertWarningCount "ConcatMap: join (map f x)" [concatMapRule]
216+ (withPrelude "x = join (map f [1])") 1
217+
218+ -- Negative cases
219+ , assertWarningCount "ConcatMap: no fire on concat alone" [concatMapRule]
220+ (withPrelude "x = concat [[1]]") 0
221+ , assertWarningCount "ConcatMap: no fire without import" [concatMapRule]
222+ (withoutImports "x = concat (map f [1])") 0
223+ ]
224+
225+-- ============================================================================
226+-- NotEqual Tests
227+-- ============================================================================
228+
229+notEqualTests :: Array TestResult
230+notEqualTests =
231+ -- Positive cases
232+ [ assertWarningCount "NotEqual: not (a == b)" [notEqualRule]
233+ (withPrelude "x = not (1 == 2)") 1
234+ , assertRuleId "NotEqual: correct rule id" [notEqualRule]
235+ (withPrelude "x = not (1 == 2)") "NotEqual"
236+ , assertWarningCount "NotEqual: not (a /= b)" [notEqualRule]
237+ (withPrelude "x = not (1 /= 2)") 1
238+
239+ -- Negative cases
240+ , assertWarningCount "NotEqual: no fire on not (a > b)" [notEqualRule]
241+ (withPrelude "x = not (1 > 2)") 0
242+ , assertWarningCount "NotEqual: no fire without import" [notEqualRule]
243+ (withoutImports "x = not (1 == 2)") 0
244+ ]
245+
246+-- ============================================================================
247+-- UseGuard Tests
248+-- ============================================================================
249+
250+useGuardTests :: Array TestResult
251+useGuardTests =
252+ -- Positive cases
253+ [ assertWarningCount "UseGuard: if cond then x else pure unit" [useGuardRule]
254+ (withPrelude "x = if true then log \"hi\" else pure unit") 1
255+ , assertRuleId "UseGuard: correct rule id" [useGuardRule]
256+ (withPrelude "x = if true then log \"hi\" else pure unit") "UseGuard"
257+ , assertWarningCount "UseGuard: if cond then pure unit else x" [useGuardRule]
258+ (withPrelude "x = if true then pure unit else log \"hi\"") 1
259+
260+ -- Negative cases
261+ , assertWarningCount "UseGuard: no fire when neither is pure unit" [useGuardRule]
262+ (withPrelude "x = if true then log \"a\" else log \"b\"") 0
263+ , assertWarningCount "UseGuard: no fire without import" [useGuardRule]
264+ (withoutImports "x = if true then log \"hi\" else pure unit") 0
265+ ]
266+
267+-- ============================================================================
268+-- EtaReduce Tests
269+-- ============================================================================
270+
271+etaReduceTests :: Array TestResult
272+etaReduceTests =
273+ -- Positive cases
274+ [ assertWarningCount "EtaReduce: \\x -> f x" [etaReduceRule]
275+ (withPrelude "x = \\a -> f a") 1
276+ , assertRuleId "EtaReduce: correct rule id" [etaReduceRule]
277+ (withPrelude "x = \\a -> f a") "EtaReduce"
278+ , assertSuggestion "EtaReduce: suggestion" [etaReduceRule]
279+ (withPrelude "x = \\a -> f a") "f"
280+
281+ -- Negative cases
282+ , assertWarningCount "EtaReduce: no fire on \\x -> f x x" [etaReduceRule]
283+ (withPrelude "x = \\a -> f a a") 0
284+ , assertWarningCount "EtaReduce: no fire on \\x -> x" [etaReduceRule]
285+ (withPrelude "x = \\a -> a") 0
286+ ]
287+
288+-- ============================================================================
289+-- EtaReduceDecl Tests
290+-- ============================================================================
291+
292+etaReduceDeclTests :: Array TestResult
293+etaReduceDeclTests =
294+ -- Positive cases
295+ [ assertWarningCount "EtaReduceDecl: foo x = bar x" [etaReduceDeclRule]
296+ (withPrelude "foo x = bar x") 1
297+ , assertRuleId "EtaReduceDecl: correct rule id" [etaReduceDeclRule]
298+ (withPrelude "foo x = bar x") "EtaReduceDecl"
299+ , assertWarningCount "EtaReduceDecl: foo a b = bar a b" [etaReduceDeclRule]
300+ (withPrelude "foo a b = bar a b") 1
301+
302+ -- Negative cases
303+ , assertWarningCount "EtaReduceDecl: no fire on foo x = bar x x" [etaReduceDeclRule]
304+ (withPrelude "foo x = bar x x") 0
305+ , assertWarningCount "EtaReduceDecl: no fire on foo x = x" [etaReduceDeclRule]
306+ (withPrelude "foo x = x") 0
307+ ]
308+
309+-- ============================================================================
310+-- RedundantBind Tests
311+-- ============================================================================
312+
313+redundantBindTests :: Array TestResult
314+redundantBindTests =
315+ -- Positive cases
316+ [ assertWarningCount "RedundantBind: x >>= pure" [redundantBindRule]
317+ (withPrelude "x = [1] >>= pure") 1
318+ , assertRuleId "RedundantBind: correct rule id" [redundantBindRule]
319+ (withPrelude "x = [1] >>= pure") "RedundantBind"
320+
321+ -- Negative cases
322+ , assertWarningCount "RedundantBind: no fire on x >>= f" [redundantBindRule]
323+ (withPrelude "x = [1] >>= show") 0
324+ , assertWarningCount "RedundantBind: no fire without import" [redundantBindRule]
325+ (withoutImports "x = [1] >>= pure") 0
326+ ]
327+
328+-- ============================================================================
329+-- LetToWhere Tests
330+-- ============================================================================
331+
332+letToWhereTests :: Array TestResult
333+letToWhereTests =
334+ -- Positive cases
335+ [ assertWarningCount "LetToWhere: let x = 1 in x + 1" [letToWhereRule]
336+ (withPrelude "x = let y = 1 in y + 1") 1
337+ , assertRuleId "LetToWhere: correct rule id" [letToWhereRule]
338+ (withPrelude "x = let y = 1 in y + 1") "LetToWhere"
339+
340+ -- Multiple bindings
341+ , assertWarningCount "LetToWhere: multiple bindings" [letToWhereRule]
342+ (withPrelude "x = let a = 1\n b = 2 in a + b") 1
343+ ]
zed-extension/Cargo.locknew file+823
@@ -0,0 +1,823 @@
1+# This file is automatically @generated by Cargo.
2+# It is not intended for manual editing.
3+version = 4
4+
5+[[package]]
6+name = "adler2"
7+version = "2.0.1"
8+source = "registry+https://github.com/rust-lang/crates.io-index"
9+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
10+
11+[[package]]
12+name = "anyhow"
13+version = "1.0.100"
14+source = "registry+https://github.com/rust-lang/crates.io-index"
15+checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
16+
17+[[package]]
18+name = "auditable-serde"
19+version = "0.8.0"
20+source = "registry+https://github.com/rust-lang/crates.io-index"
21+checksum = "5c7bf8143dfc3c0258df908843e169b5cc5fcf76c7718bd66135ef4a9cd558c5"
22+dependencies = [
23+ "semver",
24+ "serde",
25+ "serde_json",
26+ "topological-sort",
27+]
28+
29+[[package]]
30+name = "bitflags"
31+version = "2.10.0"
32+source = "registry+https://github.com/rust-lang/crates.io-index"
33+checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
34+
35+[[package]]
36+name = "cfg-if"
37+version = "1.0.4"
38+source = "registry+https://github.com/rust-lang/crates.io-index"
39+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
40+
41+[[package]]
42+name = "crc32fast"
43+version = "1.5.0"
44+source = "registry+https://github.com/rust-lang/crates.io-index"
45+checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
46+dependencies = [
47+ "cfg-if",
48+]
49+
50+[[package]]
51+name = "displaydoc"
52+version = "0.2.5"
53+source = "registry+https://github.com/rust-lang/crates.io-index"
54+checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
55+dependencies = [
56+ "proc-macro2",
57+ "quote",
58+ "syn",
59+]
60+
61+[[package]]
62+name = "equivalent"
63+version = "1.0.2"
64+source = "registry+https://github.com/rust-lang/crates.io-index"
65+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
66+
67+[[package]]
68+name = "flate2"
69+version = "1.1.8"
70+source = "registry+https://github.com/rust-lang/crates.io-index"
71+checksum = "b375d6465b98090a5f25b1c7703f3859783755aa9a80433b36e0379a3ec2f369"
72+dependencies = [
73+ "crc32fast",
74+ "miniz_oxide",
75+]
76+
77+[[package]]
78+name = "foldhash"
79+version = "0.1.5"
80+source = "registry+https://github.com/rust-lang/crates.io-index"
81+checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
82+
83+[[package]]
84+name = "form_urlencoded"
85+version = "1.2.2"
86+source = "registry+https://github.com/rust-lang/crates.io-index"
87+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
88+dependencies = [
89+ "percent-encoding",
90+]
91+
92+[[package]]
93+name = "futures"
94+version = "0.3.31"
95+source = "registry+https://github.com/rust-lang/crates.io-index"
96+checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
97+dependencies = [
98+ "futures-channel",
99+ "futures-core",
100+ "futures-executor",
101+ "futures-io",
102+ "futures-sink",
103+ "futures-task",
104+ "futures-util",
105+]
106+
107+[[package]]
108+name = "futures-channel"
109+version = "0.3.31"
110+source = "registry+https://github.com/rust-lang/crates.io-index"
111+checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
112+dependencies = [
113+ "futures-core",
114+ "futures-sink",
115+]
116+
117+[[package]]
118+name = "futures-core"
119+version = "0.3.31"
120+source = "registry+https://github.com/rust-lang/crates.io-index"
121+checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
122+
123+[[package]]
124+name = "futures-executor"
125+version = "0.3.31"
126+source = "registry+https://github.com/rust-lang/crates.io-index"
127+checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f"
128+dependencies = [
129+ "futures-core",
130+ "futures-task",
131+ "futures-util",
132+]
133+
134+[[package]]
135+name = "futures-io"
136+version = "0.3.31"
137+source = "registry+https://github.com/rust-lang/crates.io-index"
138+checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
139+
140+[[package]]
141+name = "futures-macro"
142+version = "0.3.31"
143+source = "registry+https://github.com/rust-lang/crates.io-index"
144+checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
145+dependencies = [
146+ "proc-macro2",
147+ "quote",
148+ "syn",
149+]
150+
151+[[package]]
152+name = "futures-sink"
153+version = "0.3.31"
154+source = "registry+https://github.com/rust-lang/crates.io-index"
155+checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7"
156+
157+[[package]]
158+name = "futures-task"
159+version = "0.3.31"
160+source = "registry+https://github.com/rust-lang/crates.io-index"
161+checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
162+
163+[[package]]
164+name = "futures-util"
165+version = "0.3.31"
166+source = "registry+https://github.com/rust-lang/crates.io-index"
167+checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
168+dependencies = [
169+ "futures-channel",
170+ "futures-core",
171+ "futures-io",
172+ "futures-macro",
173+ "futures-sink",
174+ "futures-task",
175+ "memchr",
176+ "pin-project-lite",
177+ "pin-utils",
178+ "slab",
179+]
180+
181+[[package]]
182+name = "hashbrown"
183+version = "0.15.5"
184+source = "registry+https://github.com/rust-lang/crates.io-index"
185+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
186+dependencies = [
187+ "foldhash",
188+]
189+
190+[[package]]
191+name = "hashbrown"
192+version = "0.16.1"
193+source = "registry+https://github.com/rust-lang/crates.io-index"
194+checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
195+
196+[[package]]
197+name = "heck"
198+version = "0.5.0"
199+source = "registry+https://github.com/rust-lang/crates.io-index"
200+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
201+
202+[[package]]
203+name = "icu_collections"
204+version = "2.1.1"
205+source = "registry+https://github.com/rust-lang/crates.io-index"
206+checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43"
207+dependencies = [
208+ "displaydoc",
209+ "potential_utf",
210+ "yoke",
211+ "zerofrom",
212+ "zerovec",
213+]
214+
215+[[package]]
216+name = "icu_locale_core"
217+version = "2.1.1"
218+source = "registry+https://github.com/rust-lang/crates.io-index"
219+checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6"
220+dependencies = [
221+ "displaydoc",
222+ "litemap",
223+ "tinystr",
224+ "writeable",
225+ "zerovec",
226+]
227+
228+[[package]]
229+name = "icu_normalizer"
230+version = "2.1.1"
231+source = "registry+https://github.com/rust-lang/crates.io-index"
232+checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599"
233+dependencies = [
234+ "icu_collections",
235+ "icu_normalizer_data",
236+ "icu_properties",
237+ "icu_provider",
238+ "smallvec",
239+ "zerovec",
240+]
241+
242+[[package]]
243+name = "icu_normalizer_data"
244+version = "2.1.1"
245+source = "registry+https://github.com/rust-lang/crates.io-index"
246+checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a"
247+
248+[[package]]
249+name = "icu_properties"
250+version = "2.1.2"
251+source = "registry+https://github.com/rust-lang/crates.io-index"
252+checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec"
253+dependencies = [
254+ "icu_collections",
255+ "icu_locale_core",
256+ "icu_properties_data",
257+ "icu_provider",
258+ "zerotrie",
259+ "zerovec",
260+]
261+
262+[[package]]
263+name = "icu_properties_data"
264+version = "2.1.2"
265+source = "registry+https://github.com/rust-lang/crates.io-index"
266+checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af"
267+
268+[[package]]
269+name = "icu_provider"
270+version = "2.1.1"
271+source = "registry+https://github.com/rust-lang/crates.io-index"
272+checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614"
273+dependencies = [
274+ "displaydoc",
275+ "icu_locale_core",
276+ "writeable",
277+ "yoke",
278+ "zerofrom",
279+ "zerotrie",
280+ "zerovec",
281+]
282+
283+[[package]]
284+name = "id-arena"
285+version = "2.3.0"
286+source = "registry+https://github.com/rust-lang/crates.io-index"
287+checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
288+
289+[[package]]
290+name = "idna"
291+version = "1.1.0"
292+source = "registry+https://github.com/rust-lang/crates.io-index"
293+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
294+dependencies = [
295+ "idna_adapter",
296+ "smallvec",
297+ "utf8_iter",
298+]
299+
300+[[package]]
301+name = "idna_adapter"
302+version = "1.2.1"
303+source = "registry+https://github.com/rust-lang/crates.io-index"
304+checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344"
305+dependencies = [
306+ "icu_normalizer",
307+ "icu_properties",
308+]
309+
310+[[package]]
311+name = "indexmap"
312+version = "2.13.0"
313+source = "registry+https://github.com/rust-lang/crates.io-index"
314+checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
315+dependencies = [
316+ "equivalent",
317+ "hashbrown 0.16.1",
318+ "serde",
319+ "serde_core",
320+]
321+
322+[[package]]
323+name = "itoa"
324+version = "1.0.17"
325+source = "registry+https://github.com/rust-lang/crates.io-index"
326+checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
327+
328+[[package]]
329+name = "leb128fmt"
330+version = "0.1.0"
331+source = "registry+https://github.com/rust-lang/crates.io-index"
332+checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
333+
334+[[package]]
335+name = "litemap"
336+version = "0.8.1"
337+source = "registry+https://github.com/rust-lang/crates.io-index"
338+checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
339+
340+[[package]]
341+name = "log"
342+version = "0.4.29"
343+source = "registry+https://github.com/rust-lang/crates.io-index"
344+checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
345+
346+[[package]]
347+name = "memchr"
348+version = "2.7.6"
349+source = "registry+https://github.com/rust-lang/crates.io-index"
350+checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
351+
352+[[package]]
353+name = "miniz_oxide"
354+version = "0.8.9"
355+source = "registry+https://github.com/rust-lang/crates.io-index"
356+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
357+dependencies = [
358+ "adler2",
359+ "simd-adler32",
360+]
361+
362+[[package]]
363+name = "once_cell"
364+version = "1.21.3"
365+source = "registry+https://github.com/rust-lang/crates.io-index"
366+checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
367+
368+[[package]]
369+name = "percent-encoding"
370+version = "2.3.2"
371+source = "registry+https://github.com/rust-lang/crates.io-index"
372+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
373+
374+[[package]]
375+name = "pin-project-lite"
376+version = "0.2.16"
377+source = "registry+https://github.com/rust-lang/crates.io-index"
378+checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
379+
380+[[package]]
381+name = "pin-utils"
382+version = "0.1.0"
383+source = "registry+https://github.com/rust-lang/crates.io-index"
384+checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
385+
386+[[package]]
387+name = "potential_utf"
388+version = "0.1.4"
389+source = "registry+https://github.com/rust-lang/crates.io-index"
390+checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77"
391+dependencies = [
392+ "zerovec",
393+]
394+
395+[[package]]
396+name = "prettyplease"
397+version = "0.2.37"
398+source = "registry+https://github.com/rust-lang/crates.io-index"
399+checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
400+dependencies = [
401+ "proc-macro2",
402+ "syn",
403+]
404+
405+[[package]]
406+name = "proc-macro2"
407+version = "1.0.106"
408+source = "registry+https://github.com/rust-lang/crates.io-index"
409+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
410+dependencies = [
411+ "unicode-ident",
412+]
413+
414+[[package]]
415+name = "purelint-zed"
416+version = "0.0.1"
417+dependencies = [
418+ "zed_extension_api",
419+]
420+
421+[[package]]
422+name = "quote"
423+version = "1.0.44"
424+source = "registry+https://github.com/rust-lang/crates.io-index"
425+checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4"
426+dependencies = [
427+ "proc-macro2",
428+]
429+
430+[[package]]
431+name = "semver"
432+version = "1.0.27"
433+source = "registry+https://github.com/rust-lang/crates.io-index"
434+checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
435+dependencies = [
436+ "serde",
437+ "serde_core",
438+]
439+
440+[[package]]
441+name = "serde"
442+version = "1.0.228"
443+source = "registry+https://github.com/rust-lang/crates.io-index"
444+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
445+dependencies = [
446+ "serde_core",
447+ "serde_derive",
448+]
449+
450+[[package]]
451+name = "serde_core"
452+version = "1.0.228"
453+source = "registry+https://github.com/rust-lang/crates.io-index"
454+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
455+dependencies = [
456+ "serde_derive",
457+]
458+
459+[[package]]
460+name = "serde_derive"
461+version = "1.0.228"
462+source = "registry+https://github.com/rust-lang/crates.io-index"
463+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
464+dependencies = [
465+ "proc-macro2",
466+ "quote",
467+ "syn",
468+]
469+
470+[[package]]
471+name = "serde_json"
472+version = "1.0.149"
473+source = "registry+https://github.com/rust-lang/crates.io-index"
474+checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
475+dependencies = [
476+ "itoa",
477+ "memchr",
478+ "serde",
479+ "serde_core",
480+ "zmij",
481+]
482+
483+[[package]]
484+name = "simd-adler32"
485+version = "0.3.8"
486+source = "registry+https://github.com/rust-lang/crates.io-index"
487+checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
488+
489+[[package]]
490+name = "slab"
491+version = "0.4.11"
492+source = "registry+https://github.com/rust-lang/crates.io-index"
493+checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589"
494+
495+[[package]]
496+name = "smallvec"
497+version = "1.15.1"
498+source = "registry+https://github.com/rust-lang/crates.io-index"
499+checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
500+
501+[[package]]
502+name = "spdx"
503+version = "0.10.9"
504+source = "registry+https://github.com/rust-lang/crates.io-index"
505+checksum = "c3e17e880bafaeb362a7b751ec46bdc5b61445a188f80e0606e68167cd540fa3"
506+dependencies = [
507+ "smallvec",
508+]
509+
510+[[package]]
511+name = "stable_deref_trait"
512+version = "1.2.1"
513+source = "registry+https://github.com/rust-lang/crates.io-index"
514+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
515+
516+[[package]]
517+name = "syn"
518+version = "2.0.114"
519+source = "registry+https://github.com/rust-lang/crates.io-index"
520+checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a"
521+dependencies = [
522+ "proc-macro2",
523+ "quote",
524+ "unicode-ident",
525+]
526+
527+[[package]]
528+name = "synstructure"
529+version = "0.13.2"
530+source = "registry+https://github.com/rust-lang/crates.io-index"
531+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
532+dependencies = [
533+ "proc-macro2",
534+ "quote",
535+ "syn",
536+]
537+
538+[[package]]
539+name = "tinystr"
540+version = "0.8.2"
541+source = "registry+https://github.com/rust-lang/crates.io-index"
542+checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869"
543+dependencies = [
544+ "displaydoc",
545+ "zerovec",
546+]
547+
548+[[package]]
549+name = "topological-sort"
550+version = "0.2.2"
551+source = "registry+https://github.com/rust-lang/crates.io-index"
552+checksum = "ea68304e134ecd095ac6c3574494fc62b909f416c4fca77e440530221e549d3d"
553+
554+[[package]]
555+name = "unicode-ident"
556+version = "1.0.22"
557+source = "registry+https://github.com/rust-lang/crates.io-index"
558+checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
559+
560+[[package]]
561+name = "unicode-xid"
562+version = "0.2.6"
563+source = "registry+https://github.com/rust-lang/crates.io-index"
564+checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
565+
566+[[package]]
567+name = "url"
568+version = "2.5.8"
569+source = "registry+https://github.com/rust-lang/crates.io-index"
570+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
571+dependencies = [
572+ "form_urlencoded",
573+ "idna",
574+ "percent-encoding",
575+ "serde",
576+]
577+
578+[[package]]
579+name = "utf8_iter"
580+version = "1.0.4"
581+source = "registry+https://github.com/rust-lang/crates.io-index"
582+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
583+
584+[[package]]
585+name = "wasm-encoder"
586+version = "0.227.1"
587+source = "registry+https://github.com/rust-lang/crates.io-index"
588+checksum = "80bb72f02e7fbf07183443b27b0f3d4144abf8c114189f2e088ed95b696a7822"
589+dependencies = [
590+ "leb128fmt",
591+ "wasmparser",
592+]
593+
594+[[package]]
595+name = "wasm-metadata"
596+version = "0.227.1"
597+source = "registry+https://github.com/rust-lang/crates.io-index"
598+checksum = "ce1ef0faabbbba6674e97a56bee857ccddf942785a336c8b47b42373c922a91d"
599+dependencies = [
600+ "anyhow",
601+ "auditable-serde",
602+ "flate2",
603+ "indexmap",
604+ "serde",
605+ "serde_derive",
606+ "serde_json",
607+ "spdx",
608+ "url",
609+ "wasm-encoder",
610+ "wasmparser",
611+]
612+
613+[[package]]
614+name = "wasmparser"
615+version = "0.227.1"
616+source = "registry+https://github.com/rust-lang/crates.io-index"
617+checksum = "0f51cad774fb3c9461ab9bccc9c62dfb7388397b5deda31bf40e8108ccd678b2"
618+dependencies = [
619+ "bitflags",
620+ "hashbrown 0.15.5",
621+ "indexmap",
622+ "semver",
623+]
624+
625+[[package]]
626+name = "wit-bindgen"
627+version = "0.41.0"
628+source = "registry+https://github.com/rust-lang/crates.io-index"
629+checksum = "10fb6648689b3929d56bbc7eb1acf70c9a42a29eb5358c67c10f54dbd5d695de"
630+dependencies = [
631+ "wit-bindgen-rt",
632+ "wit-bindgen-rust-macro",
633+]
634+
635+[[package]]
636+name = "wit-bindgen-core"
637+version = "0.41.0"
638+source = "registry+https://github.com/rust-lang/crates.io-index"
639+checksum = "92fa781d4f2ff6d3f27f3cc9b74a73327b31ca0dc4a3ef25a0ce2983e0e5af9b"
640+dependencies = [
641+ "anyhow",
642+ "heck",
643+ "wit-parser",
644+]
645+
646+[[package]]
647+name = "wit-bindgen-rt"
648+version = "0.41.0"
649+source = "registry+https://github.com/rust-lang/crates.io-index"
650+checksum = "c4db52a11d4dfb0a59f194c064055794ee6564eb1ced88c25da2cf76e50c5621"
651+dependencies = [
652+ "bitflags",
653+ "futures",
654+ "once_cell",
655+]
656+
657+[[package]]
658+name = "wit-bindgen-rust"
659+version = "0.41.0"
660+source = "registry+https://github.com/rust-lang/crates.io-index"
661+checksum = "9d0809dc5ba19e2e98661bf32fc0addc5a3ca5bf3a6a7083aa6ba484085ff3ce"
662+dependencies = [
663+ "anyhow",
664+ "heck",
665+ "indexmap",
666+ "prettyplease",
667+ "syn",
668+ "wasm-metadata",
669+ "wit-bindgen-core",
670+ "wit-component",
671+]
672+
673+[[package]]
674+name = "wit-bindgen-rust-macro"
675+version = "0.41.0"
676+source = "registry+https://github.com/rust-lang/crates.io-index"
677+checksum = "ad19eec017904e04c60719592a803ee5da76cb51c81e3f6fbf9457f59db49799"
678+dependencies = [
679+ "anyhow",
680+ "prettyplease",
681+ "proc-macro2",
682+ "quote",
683+ "syn",
684+ "wit-bindgen-core",
685+ "wit-bindgen-rust",
686+]
687+
688+[[package]]
689+name = "wit-component"
690+version = "0.227.1"
691+source = "registry+https://github.com/rust-lang/crates.io-index"
692+checksum = "635c3adc595422cbf2341a17fb73a319669cc8d33deed3a48368a841df86b676"
693+dependencies = [
694+ "anyhow",
695+ "bitflags",
696+ "indexmap",
697+ "log",
698+ "serde",
699+ "serde_derive",
700+ "serde_json",
701+ "wasm-encoder",
702+ "wasm-metadata",
703+ "wasmparser",
704+ "wit-parser",
705+]
706+
707+[[package]]
708+name = "wit-parser"
709+version = "0.227.1"
710+source = "registry+https://github.com/rust-lang/crates.io-index"
711+checksum = "ddf445ed5157046e4baf56f9138c124a0824d4d1657e7204d71886ad8ce2fc11"
712+dependencies = [
713+ "anyhow",
714+ "id-arena",
715+ "indexmap",
716+ "log",
717+ "semver",
718+ "serde",
719+ "serde_derive",
720+ "serde_json",
721+ "unicode-xid",
722+ "wasmparser",
723+]
724+
725+[[package]]
726+name = "writeable"
727+version = "0.6.2"
728+source = "registry+https://github.com/rust-lang/crates.io-index"
729+checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
730+
731+[[package]]
732+name = "yoke"
733+version = "0.8.1"
734+source = "registry+https://github.com/rust-lang/crates.io-index"
735+checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954"
736+dependencies = [
737+ "stable_deref_trait",
738+ "yoke-derive",
739+ "zerofrom",
740+]
741+
742+[[package]]
743+name = "yoke-derive"
744+version = "0.8.1"
745+source = "registry+https://github.com/rust-lang/crates.io-index"
746+checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d"
747+dependencies = [
748+ "proc-macro2",
749+ "quote",
750+ "syn",
751+ "synstructure",
752+]
753+
754+[[package]]
755+name = "zed_extension_api"
756+version = "0.7.0"
757+source = "registry+https://github.com/rust-lang/crates.io-index"
758+checksum = "0729d50b4ca0a7e28e590bbe32e3ca0194d97ef654961451a424c661a366fca0"
759+dependencies = [
760+ "serde",
761+ "serde_json",
762+ "wit-bindgen",
763+]
764+
765+[[package]]
766+name = "zerofrom"
767+version = "0.1.6"
768+source = "registry+https://github.com/rust-lang/crates.io-index"
769+checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5"
770+dependencies = [
771+ "zerofrom-derive",
772+]
773+
774+[[package]]
775+name = "zerofrom-derive"
776+version = "0.1.6"
777+source = "registry+https://github.com/rust-lang/crates.io-index"
778+checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
779+dependencies = [
780+ "proc-macro2",
781+ "quote",
782+ "syn",
783+ "synstructure",
784+]
785+
786+[[package]]
787+name = "zerotrie"
788+version = "0.2.3"
789+source = "registry+https://github.com/rust-lang/crates.io-index"
790+checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851"
791+dependencies = [
792+ "displaydoc",
793+ "yoke",
794+ "zerofrom",
795+]
796+
797+[[package]]
798+name = "zerovec"
799+version = "0.11.5"
800+source = "registry+https://github.com/rust-lang/crates.io-index"
801+checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002"
802+dependencies = [
803+ "yoke",
804+ "zerofrom",
805+ "zerovec-derive",
806+]
807+
808+[[package]]
809+name = "zerovec-derive"
810+version = "0.11.2"
811+source = "registry+https://github.com/rust-lang/crates.io-index"
812+checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3"
813+dependencies = [
814+ "proc-macro2",
815+ "quote",
816+ "syn",
817+]
818+
819+[[package]]
820+name = "zmij"
821+version = "1.0.16"
822+source = "registry+https://github.com/rust-lang/crates.io-index"
823+checksum = "dfcd145825aace48cff44a8844de64bf75feec3080e0aa5cdbde72961ae51a65"
zed-extension/Cargo.tomlnew file+10
@@ -0,0 +1,10 @@
1+[package]
2+name = "purelint-zed"
3+version = "0.0.1"
4+edition = "2021"
5+
6+[lib]
7+crate-type = ["cdylib"]
8+
9+[dependencies]
10+zed_extension_api = "0.7.0"
zed-extension/extension.tomlnew file+13
@@ -0,0 +1,13 @@
1+id = "purelint"
2+name = "Purelint"
3+version = "0.0.1"
4+schema_version = 1
5+authors = ["Mark"]
6+description = "PureScript linter inspired by hlint"
7+
8+[lib]
9+kind = "Rust"
10+version = "0.7.0"
11+
12+[language_servers.purelint]
13+language = "PureScript"
zed-extension/extension.wasmnew file

Binary file not shown.

zed-extension/src/lib.rsnew file+23
@@ -0,0 +1,23 @@
1+use zed_extension_api::{self as zed, Result};
2+
3+struct PurelintExtension;
4+
5+impl zed::Extension for PurelintExtension {
6+ fn new() -> Self {
7+ PurelintExtension
8+ }
9+
10+ fn language_server_command(
11+ &mut self,
12+ _language_server_id: &zed::LanguageServerId,
13+ _worktree: &zed::Worktree,
14+ ) -> Result<zed::Command> {
15+ Ok(zed::Command {
16+ command: "/Users/mark/Developer/purelint/purelint-lsp".to_string(),
17+ args: vec![],
18+ env: Default::default(),
19+ })
20+ }
21+}
22+
23+zed::register_extension!(PurelintExtension);