├── .github ├── ISSUE_TEMPLATE │ └── new-rule-idea.md └── workflows │ └── test.yml ├── .gitignore ├── CODEOWNERS ├── README.md ├── birdie_snapshots ├── no_deprecated_functions_used.accepted ├── no_panic_inside_use.accepted ├── no_panic_inside_use_call.accepted ├── no_panic_test.accepted ├── no_trailing_underscore_test.accepted ├── no_unnecessary_empty_string_concatenation_test.accepted └── no_unnecessary_string_concatenation_test.accepted ├── gleam.toml ├── manifest.toml ├── src ├── code_review.gleam └── code_review │ ├── internal │ └── project.gleam │ ├── rule.gleam │ ├── rules │ ├── no_deprecated.gleam │ ├── no_panic.gleam │ ├── no_trailing_underscore.gleam │ └── no_unnecessary_string_concatenation.gleam │ └── setup.gleam └── test └── code_review_test.gleam /.github/ISSUE_TEMPLATE/new-rule-idea.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: New rule idea 3 | about: Propose a new rule idea 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 16 | 17 | 18 | **What the rule should do:** 19 | 20 | 21 | **What problems does it solve:** 22 | 23 | 24 | **Example of things the rule would report:** 25 | 26 | ```gleam 27 | 28 | ``` 29 | 30 | **Example of things the rule would not report:** 31 | 32 | ```gleam 33 | 34 | ``` 35 | 36 | **When (not) to enable this rule:** 37 | 38 | 40 | 41 | **I am looking for:** 42 | 43 | 48 | 49 | 50 | 51 | 52 | 53 | 55 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | env: 4 | GLEAM_VERSION: "1.1.0" 5 | 6 | on: 7 | push: 8 | branches: 9 | - main 10 | pull_request: 11 | 12 | jobs: 13 | format: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: erlef/setup-beam@v1 18 | with: 19 | otp-version: 26 20 | rebar3-version: 3 21 | gleam-version: ${{ env.GLEAM_VERSION }} 22 | - run: gleam format --check src test 23 | 24 | deps: 25 | runs-on: ubuntu-latest 26 | steps: 27 | - uses: actions/checkout@v4 28 | - name: Check cache 29 | uses: actions/cache/restore@v4 30 | id: restore 31 | with: 32 | path: ./build/packages 33 | key: deps-${{ hashFiles('manifest.toml') }} 34 | - if: ${{ steps.restore.outputs.cache-hit != 'true' }} 35 | uses: erlef/setup-beam@v1 36 | with: 37 | otp-version: 26 38 | rebar3-version: 3 39 | gleam-version: ${{ env.GLEAM_VERSION }} 40 | - if: ${{ steps.restore.outputs.cache-hit != 'true' }} 41 | run: gleam deps download 42 | - if: ${{ steps.restore.outputs.cache-hit != 'true' }} 43 | uses: actions/cache/save@v4 44 | with: 45 | path: ./build/packages 46 | key: deps-${{ hashFiles('manifest.toml') }} 47 | 48 | test_erlang: 49 | runs-on: ubuntu-latest 50 | needs: deps 51 | strategy: 52 | fail-fast: true 53 | matrix: 54 | erlang: ["26", "25", "27.0-rc3"] 55 | steps: 56 | - uses: actions/checkout@v4 57 | - uses: actions/cache/restore@v4 58 | with: 59 | path: ./build/packages 60 | key: deps-${{ hashFiles('manifest.toml') }} 61 | - uses: erlef/setup-beam@v1 62 | with: 63 | otp-version: ${{matrix.erlang}} 64 | rebar3-version: 3 65 | gleam-version: ${{env.GLEAM_VERSION}} 66 | - run: gleam test --target erlang 67 | 68 | test_node: 69 | runs-on: ubuntu-latest 70 | needs: deps 71 | strategy: 72 | fail-fast: true 73 | matrix: 74 | node: ["20", "18"] 75 | steps: 76 | - uses: actions/checkout@v4 77 | - uses: actions/cache/restore@v4 78 | with: 79 | path: ./build/packages 80 | key: deps-${{ hashFiles('manifest.toml') }} 81 | - uses: erlef/setup-beam@v1 82 | with: 83 | otp-version: 26 84 | gleam-version: ${{env.GLEAM_VERSION}} 85 | - uses: actions/setup-node@v4 86 | with: 87 | node-version: ${{matrix.node}} 88 | - run: gleam test --target javascript 89 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.beam 2 | *.ez 3 | /build 4 | erl_crash.dump 5 | .mise.toml 6 | .tool-versions 7 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | .github/ @tanklesxl 2 | 3 | * @bcpeinhardt @tanklesxl @giacomocavalieri 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # code_review 2 | -------------------------------------------------------------------------------- /birdie_snapshots/no_deprecated_functions_used.accepted: -------------------------------------------------------------------------------- 1 | --- 2 | version: 1.1.2 3 | title: No Deprecated Functions Used 4 | file: ./test/code_review_test.gleam 5 | test_name: no_deprecated_test 6 | --- 7 | Path: mocked 8 | 9 | Location Identifier: main 10 | Rule: no_deprecated 11 | Error: Found usage of deprecated function 12 | Details: Don't use this anymore. -------------------------------------------------------------------------------- /birdie_snapshots/no_panic_inside_use.accepted: -------------------------------------------------------------------------------- 1 | --- 2 | version: 1.1.2 3 | title: No Panic Inside Use 4 | file: ./test/code_review_test.gleam 5 | test_name: no_panic_inside_use_test 6 | --- 7 | Path: mocked 8 | 9 | Location Identifier: panic_inside_use 10 | Rule: no_panic 11 | Error: Found `panic` 12 | Details: This keyword should almost never be used! It may be useful in initial prototypes and scripts, but its use in a library or production application is a sign that the design could be improved. 13 | With well designed types the type system can typically be used to make these invalid states unrepresentable. -------------------------------------------------------------------------------- /birdie_snapshots/no_panic_inside_use_call.accepted: -------------------------------------------------------------------------------- 1 | --- 2 | version: 1.1.2 3 | title: No Panic Inside Use Call 4 | file: ./test/code_review_test.gleam 5 | test_name: no_panic_inside_use_call_test 6 | --- 7 | Path: mocked 8 | 9 | Location Identifier: panic_in_use_call 10 | Rule: no_panic 11 | Error: Found `panic` 12 | Details: This keyword should almost never be used! It may be useful in initial prototypes and scripts, but its use in a library or production application is a sign that the design could be improved. 13 | With well designed types the type system can typically be used to make these invalid states unrepresentable. -------------------------------------------------------------------------------- /birdie_snapshots/no_panic_test.accepted: -------------------------------------------------------------------------------- 1 | --- 2 | version: 1.1.2 3 | title: No Panic Test 4 | file: ./test/code_review_test.gleam 5 | test_name: basic_panic_test 6 | --- 7 | Path: mocked 8 | 9 | Location Identifier: this_code_panics 10 | Rule: no_panic 11 | Error: Found `panic` 12 | Details: This keyword should almost never be used! It may be useful in initial prototypes and scripts, but its use in a library or production application is a sign that the design could be improved. 13 | With well designed types the type system can typically be used to make these invalid states unrepresentable. -------------------------------------------------------------------------------- /birdie_snapshots/no_trailing_underscore_test.accepted: -------------------------------------------------------------------------------- 1 | --- 2 | version: 1.1.2 3 | title: No Trailing Underscore Test 4 | file: ./test/code_review_test.gleam 5 | test_name: no_trailing_underscore_test 6 | --- 7 | Path: mocked 8 | 9 | Location Identifier: with_trailing_ 10 | Rule: no_trailing_underscore 11 | Error: Trailing underscore in function name 12 | Details: We don't like no trailing underscores. -------------------------------------------------------------------------------- /birdie_snapshots/no_unnecessary_empty_string_concatenation_test.accepted: -------------------------------------------------------------------------------- 1 | --- 2 | version: 1.1.2 3 | title: No Unnecessary Empty String Concatenation Test 4 | file: ./test/code_review_test.gleam 5 | test_name: no_unnecessary_empty_string_concatenation_test 6 | --- 7 | Path: mocked 8 | 9 | Location Identifier: concat_empty 10 | Rule: no_unnecessary_string_concatenation 11 | Error: Unnecessary concatenation with an empty string 12 | Details: The result of adding an empty string to an expression is the expression itself. 13 | You can remove the concatenation with "". 14 | 15 | 16 | Path: mocked 17 | 18 | Location Identifier: concat_empty 19 | Rule: no_unnecessary_string_concatenation 20 | Error: Unnecessary concatenation with an empty string 21 | Details: The result of adding an empty string to an expression is the expression itself. 22 | You can remove the concatenation with "". -------------------------------------------------------------------------------- /birdie_snapshots/no_unnecessary_string_concatenation_test.accepted: -------------------------------------------------------------------------------- 1 | --- 2 | version: 1.1.2 3 | title: No Unnecessary String Concatenation Test 4 | file: ./test/code_review_test.gleam 5 | test_name: no_unnecessary_string_concatenation_test 6 | --- 7 | Path: mocked 8 | 9 | Location Identifier: string_concatenation 10 | Rule: no_unnecessary_string_concatenation 11 | Error: Unnecessary concatenation of string literals 12 | Details: Instead of concatenating these two string literals, they can be written as a single one. 13 | For instance, instead of "a" <> "b", you could write that as "ab". -------------------------------------------------------------------------------- /gleam.toml: -------------------------------------------------------------------------------- 1 | name = "code_review" 2 | version = "1.0.0" 3 | 4 | gleam = ">= 1.0.0" 5 | 6 | # Fill out these fields if you intend to generate HTML documentation or publish 7 | # your project to the Hex package manager. 8 | # 9 | # description = "" 10 | # licences = ["Apache-2.0"] 11 | # repository = { type = "github", user = "username", repo = "project" } 12 | # links = [{ title = "Website", href = "https://gleam.run" }] 13 | # 14 | # For a full reference of all the available options, you can have a look at 15 | # https://gleam.run/writing-gleam/gleam-toml/. 16 | 17 | [dependencies] 18 | gleam_stdlib = ">= 0.34.0 and < 2.0.0" 19 | glance = ">= 0.8.2 and < 1.0.0" 20 | simplifile = ">= 1.7.0 and < 2.0.0" 21 | filepath = ">= 1.0.0 and < 2.0.0" 22 | tom = ">= 0.3.0 and < 1.0.0" 23 | 24 | [dev-dependencies] 25 | gleeunit = ">= 1.0.0 and < 2.0.0" 26 | birdie = ">= 1.1.2 and < 2.0.0" 27 | -------------------------------------------------------------------------------- /manifest.toml: -------------------------------------------------------------------------------- 1 | # This file was generated by Gleam 2 | # You typically do not need to edit this file 3 | 4 | packages = [ 5 | { name = "argv", version = "1.0.2", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "BA1FF0929525DEBA1CE67256E5ADF77A7CDDFE729E3E3F57A5BDCAA031DED09D" }, 6 | { name = "birdie", version = "1.1.2", build_tools = ["gleam"], requirements = ["argv", "filepath", "glance", "gleam_community_ansi", "gleam_erlang", "gleam_stdlib", "justin", "rank", "simplifile", "trie_again"], otp_app = "birdie", source = "hex", outer_checksum = "F9666AEB5F6EDFAE6ADF9DFBF10EF96A4EDBDDB84B854C29B9A3F615A6436311" }, 7 | { name = "filepath", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "EFB6FF65C98B2A16378ABC3EE2B14124168C0CE5201553DE652E2644DCFDB594" }, 8 | { name = "glance", version = "0.8.2", build_tools = ["gleam"], requirements = ["gleam_stdlib", "glexer"], otp_app = "glance", source = "hex", outer_checksum = "ACF09457E8B564AD7A0D823DAFDD326F58263C01ACB0D432A9BEFDEDD1DA8E73" }, 9 | { name = "gleam_community_ansi", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_stdlib"], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "FE79E08BF97009729259B6357EC058315B6FBB916FAD1C2FF9355115FEB0D3A4" }, 10 | { name = "gleam_community_colour", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "795964217EBEDB3DA656F5EB8F67D7AD22872EB95182042D3E7AFEF32D3FD2FE" }, 11 | { name = "gleam_erlang", version = "0.25.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "054D571A7092D2A9727B3E5D183B7507DAB0DA41556EC9133606F09C15497373" }, 12 | { name = "gleam_json", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "thoas"], otp_app = "gleam_json", source = "hex", outer_checksum = "8B197DD5D578EA6AC2C0D4BDC634C71A5BCA8E7DB5F47091C263ECB411A60DF3" }, 13 | { name = "gleam_stdlib", version = "0.36.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "C0D14D807FEC6F8A08A7C9EF8DFDE6AE5C10E40E21325B2B29365965D82EB3D4" }, 14 | { name = "gleeunit", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "72CDC3D3F719478F26C4E2C5FED3E657AC81EC14A47D2D2DEBB8693CA3220C3B" }, 15 | { name = "glexer", version = "0.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "glexer", source = "hex", outer_checksum = "4484942A465482A0A100936E1E5F12314DB4B5AC0D87575A7B9E9062090B96BE" }, 16 | { name = "justin", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "justin", source = "hex", outer_checksum = "7FA0C6DB78640C6DC5FBFD59BF3456009F3F8B485BF6825E97E1EB44E9A1E2CD" }, 17 | { name = "rank", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "rank", source = "hex", outer_checksum = "5660E361F0E49CBB714CC57CC4C89C63415D8986F05B2DA0C719D5642FAD91C9" }, 18 | { name = "simplifile", version = "1.7.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "1D5DFA3A2F9319EC85825F6ED88B8E449F381B0D55A62F5E61424E748E7DDEB0" }, 19 | { name = "thoas", version = "0.4.1", build_tools = ["rebar3"], requirements = [], otp_app = "thoas", source = "hex", outer_checksum = "4918D50026C073C4AB1388437132C77A6F6F7C8AC43C60C13758CC0ADCE2134E" }, 20 | { name = "tom", version = "0.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "tom", source = "hex", outer_checksum = "0831C73E45405A2153091226BF98FB485ED16376988602CC01A5FD086B82D577" }, 21 | { name = "trie_again", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "trie_again", source = "hex", outer_checksum = "5B19176F52B1BD98831B57FDC97BD1F88C8A403D6D8C63471407E78598E27184" }, 22 | ] 23 | 24 | [requirements] 25 | birdie = { version = ">= 1.1.2 and < 2.0.0" } 26 | filepath = { version = ">= 1.0.0 and < 2.0.0" } 27 | glance = { version = ">= 0.8.2 and < 1.0.0" } 28 | gleam_stdlib = { version = ">= 0.34.0 and < 2.0.0" } 29 | gleeunit = { version = ">= 1.0.0 and < 2.0.0" } 30 | simplifile = { version = ">= 1.7.0 and < 2.0.0" } 31 | tom = { version = ">= 0.3.0 and < 1.0.0" } 32 | -------------------------------------------------------------------------------- /src/code_review.gleam: -------------------------------------------------------------------------------- 1 | //// A linter for Gleam, written in Gleam. Staring with a very basic prototype 2 | //// setup: read in the gleam files, iterate over them searching for common 3 | //// patterns based on the glance module that gets parsed, and produce messages 4 | //// pointing out the issue. 5 | 6 | import code_review/internal/project.{type Project} 7 | import code_review/rule.{type Rule} 8 | import glance 9 | import gleam/io 10 | import gleam/list 11 | import gleam/option.{None, Some} 12 | import gleam/result 13 | 14 | // RUNNING THE LINTER ---------------------------------------------------------- 15 | 16 | pub fn run(rules: List(Rule)) -> Nil { 17 | let run_result = { 18 | use knowledge_base <- result.try(project.read(project.root())) 19 | Ok(visit(knowledge_base, rules)) 20 | } 21 | 22 | case run_result { 23 | Ok(rule_errors) -> 24 | list.each(rule_errors, fn(rule_error) { 25 | rule.pretty_print_error(rule_error) 26 | |> io.println_error 27 | }) 28 | 29 | Error(project_error) -> 30 | project.explain_error(project_error) 31 | |> io.println_error 32 | } 33 | } 34 | 35 | /// TODO: once Gleam goes v1.1 this could be marked as internal, I don't think 36 | /// we should expose it in the public API. 37 | /// I feel the `code_review` module should only publicly expose the `main` 38 | /// function that acts as the CLI entry point. 39 | pub fn visit(project: Project, rules: List(Rule)) -> List(rule.Error) { 40 | let rule_visitors = list.map(rules, rule.module_visitor) 41 | 42 | use acc, project.Module(path, module) <- list.fold(project.src_modules, []) 43 | visit_module(module, rule_visitors) 44 | |> list.flat_map(fn(rule) { rule.get_errors() }) 45 | |> list.map(rule.set_error_path(_, path)) 46 | |> list.append(acc) 47 | } 48 | 49 | fn visit_module( 50 | module: glance.Module, 51 | rules: List(rule.ModuleVisitor), 52 | ) -> List(rule.ModuleVisitor) { 53 | let glance.Module(constants: constants, functions: functions, ..) = module 54 | 55 | rules 56 | |> visit_constants(constants) 57 | |> visit_functions(functions) 58 | } 59 | 60 | fn visit_constants( 61 | rules: List(rule.ModuleVisitor), 62 | constants: List(glance.Definition(glance.Constant)), 63 | ) -> List(rule.ModuleVisitor) { 64 | use rules_acc, constant_with_definition <- list.fold(constants, rules) 65 | let glance.Definition(_, c) = constant_with_definition 66 | do_visit_expressions(rules_acc, c.value) 67 | } 68 | 69 | fn visit_functions( 70 | rules: List(rule.ModuleVisitor), 71 | functions: List(glance.Definition(glance.Function)), 72 | ) -> List(rule.ModuleVisitor) { 73 | list.fold(functions, rules, visit_function) 74 | } 75 | 76 | fn visit_function( 77 | rules_before_visit: List(rule.ModuleVisitor), 78 | function: glance.Definition(glance.Function), 79 | ) -> List(rule.ModuleVisitor) { 80 | let rules_after_function_visit: List(rule.ModuleVisitor) = 81 | apply_visitor(function, rules_before_visit, fn(rule) { 82 | rule.function_visitor 83 | }) 84 | 85 | let glance.Definition(_, func) = function 86 | list.fold(func.body, rules_after_function_visit, visit_statement) 87 | } 88 | 89 | fn visit_statement( 90 | rules: List(rule.ModuleVisitor), 91 | statement: glance.Statement, 92 | ) -> List(rule.ModuleVisitor) { 93 | case statement { 94 | glance.Use(_, expr) -> do_visit_expressions(rules, expr) 95 | glance.Assignment(value: val, ..) -> do_visit_expressions(rules, val) 96 | glance.Expression(expr) -> do_visit_expressions(rules, expr) 97 | } 98 | } 99 | 100 | fn apply_visitor( 101 | a: a, 102 | rules: List(rule.ModuleVisitor), 103 | get_visitor: fn(rule.ModuleVisitor) -> 104 | option.Option(fn(a) -> rule.ModuleVisitor), 105 | ) -> List(rule.ModuleVisitor) { 106 | use rule <- list.map(rules) 107 | case get_visitor(rule) { 108 | option.None -> rule 109 | option.Some(visitor) -> visitor(a) 110 | } 111 | } 112 | 113 | fn do_visit_expressions( 114 | rules_before_visit: List(rule.ModuleVisitor), 115 | input: glance.Expression, 116 | ) -> List(rule.ModuleVisitor) { 117 | let rules: List(rule.ModuleVisitor) = 118 | apply_visitor(input, rules_before_visit, fn(rule) { 119 | rule.expression_visitor 120 | }) 121 | 122 | case input { 123 | glance.Todo(_) 124 | | glance.Panic(_) 125 | | glance.Int(_) 126 | | glance.Float(_) 127 | | glance.String(_) 128 | | glance.Variable(_) -> rules 129 | 130 | glance.NegateInt(expr) | glance.NegateBool(expr) -> 131 | do_visit_expressions(rules, expr) 132 | 133 | glance.Block(statements) -> { 134 | visit_statements(rules, statements) 135 | } 136 | glance.Tuple(exprs) -> list.fold(exprs, rules, do_visit_expressions) 137 | glance.List(exprs, rest) -> { 138 | list.fold(exprs, rules, do_visit_expressions) 139 | |> fn(new_rules) { 140 | case rest { 141 | Some(rest_expr) -> do_visit_expressions(new_rules, rest_expr) 142 | None -> new_rules 143 | } 144 | } 145 | } 146 | glance.Fn(arguments: _, return_annotation: _, body: statements) -> { 147 | visit_statements(rules, statements) 148 | } 149 | glance.RecordUpdate( 150 | module: _, 151 | constructor: _, 152 | record: record, 153 | fields: fields, 154 | ) -> { 155 | let new_rules = do_visit_expressions(rules, record) 156 | 157 | use acc_rules, #(_, expr) <- list.fold(fields, new_rules) 158 | do_visit_expressions(acc_rules, expr) 159 | } 160 | glance.FieldAccess(container: container, label: _) -> 161 | do_visit_expressions(rules, container) 162 | glance.Call(function, arguments) -> { 163 | let new_rules = do_visit_expressions(rules, function) 164 | 165 | use acc_rules, arg <- list.fold(arguments, new_rules) 166 | do_visit_expressions(acc_rules, arg.item) 167 | } 168 | glance.TupleIndex(expr, index: _) -> { 169 | do_visit_expressions(rules, expr) 170 | } 171 | glance.FnCapture( 172 | label: _, 173 | function: function, 174 | arguments_before: arguments_before, 175 | arguments_after: arguments_after, 176 | ) -> { 177 | list.fold( 178 | list.append(arguments_before, arguments_after), 179 | rules, 180 | fn(acc_rules, arg) { do_visit_expressions(acc_rules, arg.item) }, 181 | ) 182 | |> do_visit_expressions(function) 183 | } 184 | glance.BitString(segments) -> { 185 | use acc_rules, #(expr, _) <- list.fold(segments, rules) 186 | do_visit_expressions(acc_rules, expr) 187 | } 188 | glance.Case(subjects, clauses) -> { 189 | let new_rules = list.fold(subjects, rules, do_visit_expressions) 190 | 191 | use acc_rules, c <- list.fold(clauses, new_rules) 192 | let glance.Clause(_, guard, body) = c 193 | let acc_rules_2 = do_visit_expressions(acc_rules, body) 194 | case guard { 195 | Some(expr) -> do_visit_expressions(rules, expr) 196 | None -> acc_rules_2 197 | } 198 | } 199 | glance.BinaryOperator(name: _, left: left, right: right) -> { 200 | rules 201 | |> do_visit_expressions(left) 202 | |> do_visit_expressions(right) 203 | } 204 | } 205 | } 206 | 207 | fn visit_statements( 208 | initial_rules: List(rule.ModuleVisitor), 209 | statements: List(glance.Statement), 210 | ) -> List(rule.ModuleVisitor) { 211 | use rules, stmt <- list.fold(statements, initial_rules) 212 | case stmt { 213 | glance.Use(_, expr) -> do_visit_expressions(rules, expr) 214 | glance.Assignment(value: expr, ..) -> do_visit_expressions(rules, expr) 215 | glance.Expression(expr) -> do_visit_expressions(rules, expr) 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /src/code_review/internal/project.gleam: -------------------------------------------------------------------------------- 1 | //// Read and parse all useful info about a Gleam project. 2 | //// 3 | 4 | import filepath 5 | import glance 6 | import gleam/dict.{type Dict} 7 | import gleam/list 8 | import gleam/result 9 | import simplifile 10 | import tom 11 | 12 | // TYPES ----------------------------------------------------------------------- 13 | 14 | /// A Gleam project as seen by the linter. 15 | /// 16 | pub type Project { 17 | Project( 18 | /// The project's source modules. 19 | /// 20 | src_modules: List(Module), 21 | /// The project's `gleam.toml`. 22 | /// 23 | config: Dict(String, tom.Toml), 24 | ) 25 | } 26 | 27 | /// A Gleam project's parsed module. 28 | /// 29 | pub type Module { 30 | Module( 31 | /// The "name" of the module is the path from the root of the project to the 32 | /// file with the `.gleam` ending removed. 33 | /// 34 | name: String, 35 | /// The parsed source code in the module. 36 | /// 37 | src: glance.Module, 38 | ) 39 | } 40 | 41 | pub type Error { 42 | CannotListSrcModules(reason: simplifile.FileError, path: String) 43 | CannotReadSrcModule(reason: simplifile.FileError, path: String) 44 | CannotParseSrcModule(reason: glance.Error, path: String) 45 | CannotReadConfig(reason: simplifile.FileError, path: String) 46 | CannotParseConfig(reason: tom.ParseError) 47 | } 48 | 49 | // READING A PROJECT ----------------------------------------------------------- 50 | 51 | /// Reads in all the information the linter needs from the project. 52 | /// 53 | pub fn read(from path: String) -> Result(Project, Error) { 54 | use config <- result.try(read_config(path)) 55 | use src_modules <- result.try(read_src_modules(path)) 56 | Ok(Project(src_modules, config)) 57 | } 58 | 59 | fn read_config(root: String) -> Result(Dict(String, tom.Toml), Error) { 60 | let config_path = filepath.join(root, "gleam.toml") 61 | 62 | use raw_config <- result.try( 63 | simplifile.read(config_path) 64 | |> result.map_error(CannotReadConfig(_, config_path)), 65 | ) 66 | 67 | tom.parse(raw_config) 68 | |> result.map_error(CannotParseConfig) 69 | } 70 | 71 | fn read_src_modules(root: String) -> Result(List(Module), Error) { 72 | let src_path = filepath.join(root, "src") 73 | 74 | use src_files <- result.try( 75 | simplifile.get_files(src_path) 76 | |> result.map_error(CannotListSrcModules(_, src_path)), 77 | ) 78 | 79 | list.try_map(src_files, read_src_module) 80 | } 81 | 82 | fn read_src_module(from path: String) -> Result(Module, Error) { 83 | use raw_src_module <- result.try( 84 | simplifile.read(path) 85 | |> result.map_error(CannotReadSrcModule(_, path)), 86 | ) 87 | 88 | use ast <- result.try( 89 | glance.module(raw_src_module) 90 | |> result.map_error(CannotParseSrcModule(_, path)), 91 | ) 92 | 93 | Ok(Module(path, ast)) 94 | } 95 | 96 | // ERROR REPORTING ------------------------------------------------------------- 97 | 98 | pub fn explain_error(error: Error) -> String { 99 | case error { 100 | CannotListSrcModules(..) -> todo as "properly explain CannotListSrcModules" 101 | CannotReadSrcModule(..) -> todo as "properly explain CannotReadSrcModule" 102 | CannotParseSrcModule(..) -> todo as "properly explain CannotParseSrcModule" 103 | CannotReadConfig(..) -> todo as "properly explain CannotReadConfig" 104 | CannotParseConfig(..) -> todo as "properly explain CannotParseConfig" 105 | } 106 | } 107 | 108 | // UTILS ----------------------------------------------------------------------- 109 | 110 | /// Finds the path leading to the project's root folder. This recursively walks 111 | /// up from the current directory until it finds a `gleam.toml`. 112 | /// 113 | /// This is needed since `gleam run` can be run anywhere inside the project! 114 | /// 115 | pub fn root() -> String { 116 | find_root(".") 117 | } 118 | 119 | fn find_root(path: String) -> String { 120 | let toml = filepath.join(path, "gleam.toml") 121 | 122 | case simplifile.verify_is_file(toml) { 123 | Ok(False) | Error(_) -> find_root(filepath.join("..", path)) 124 | Ok(True) -> path 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/code_review/rule.gleam: -------------------------------------------------------------------------------- 1 | import glance 2 | import gleam/list 3 | import gleam/option 4 | import gleam/string 5 | 6 | // TYPES ----------------------------------------------------------------------- 7 | 8 | pub opaque type Rule { 9 | Rule(name: String, module_visitor: fn() -> ModuleVisitor) 10 | } 11 | 12 | pub type ModuleVisitor { 13 | ModuleVisitor( 14 | expression_visitor: option.Option(fn(glance.Expression) -> ModuleVisitor), 15 | function_visitor: option.Option( 16 | fn(glance.Definition(glance.Function)) -> ModuleVisitor, 17 | ), 18 | get_errors: fn() -> List(Error), 19 | ) 20 | } 21 | 22 | pub opaque type RuleSchema(context) { 23 | RuleSchema( 24 | name: String, 25 | initial_context: context, 26 | function_visitor: option.Option( 27 | fn(glance.Definition(glance.Function), context) -> 28 | ErrorsAndContext(context), 29 | ), 30 | expression_visitor: option.Option( 31 | fn(glance.Expression, context) -> ErrorsAndContext(context), 32 | ), 33 | ) 34 | } 35 | 36 | /// An error reported by rules. 37 | /// 38 | pub opaque type Error { 39 | Error( 40 | path: String, 41 | location_identifier: String, 42 | rule: String, 43 | message: String, 44 | details: List(String), 45 | ) 46 | } 47 | 48 | type ErrorsAndContext(context) = 49 | #(List(Error), context) 50 | 51 | // BUILDING RULES -------------------------------------------------------------- 52 | 53 | pub fn to_rule(schema: RuleSchema(context)) -> Rule { 54 | Rule(name: schema.name, module_visitor: fn() { 55 | rule_to_operations(schema, #([], schema.initial_context)) 56 | }) 57 | } 58 | 59 | pub fn new(name: String, initial_context: context) -> RuleSchema(context) { 60 | RuleSchema( 61 | name: name, 62 | initial_context: initial_context, 63 | function_visitor: option.None, 64 | expression_visitor: option.None, 65 | ) 66 | } 67 | 68 | pub fn with_function_visitor( 69 | schema: RuleSchema(context), 70 | visitor: fn(glance.Definition(glance.Function), context) -> 71 | ErrorsAndContext(context), 72 | ) -> RuleSchema(context) { 73 | let new_visitor = combine_visitors(visitor, schema.function_visitor) 74 | RuleSchema(..schema, function_visitor: option.Some(new_visitor)) 75 | } 76 | 77 | pub fn with_simple_function_visitor( 78 | schema: RuleSchema(context), 79 | visitor: fn(glance.Definition(glance.Function)) -> List(Error), 80 | ) -> RuleSchema(context) { 81 | let simple_visitor = fn(fn_, context) { #(visitor(fn_), context) } 82 | with_function_visitor(schema, simple_visitor) 83 | } 84 | 85 | pub fn with_expression_visitor( 86 | schema: RuleSchema(context), 87 | visitor: fn(glance.Expression, context) -> ErrorsAndContext(context), 88 | ) -> RuleSchema(context) { 89 | let new_visitor = combine_visitors(visitor, schema.expression_visitor) 90 | RuleSchema(..schema, expression_visitor: option.Some(new_visitor)) 91 | } 92 | 93 | pub fn with_simple_expression_visitor( 94 | schema: RuleSchema(context), 95 | visitor: fn(glance.Expression) -> List(Error), 96 | ) -> RuleSchema(context) { 97 | let simple_visitor = fn(expr, context) { #(visitor(expr), context) } 98 | with_expression_visitor(schema, simple_visitor) 99 | } 100 | 101 | fn combine_visitors( 102 | new_visitor: fn(a, context) -> ErrorsAndContext(context), 103 | previous_visitor: option.Option(fn(a, context) -> ErrorsAndContext(context)), 104 | ) -> fn(a, context) -> ErrorsAndContext(context) { 105 | case previous_visitor { 106 | option.None -> new_visitor 107 | option.Some(previous_visitor) -> fn(a, context) { 108 | let #(errors_after_first_visit, context_after_first_visit) = 109 | previous_visitor(a, context) 110 | let #(errors_after_second_visit, context_after_second_visit) = 111 | new_visitor(a, context_after_first_visit) 112 | let all_errors = 113 | list.append(errors_after_first_visit, errors_after_second_visit) 114 | 115 | #(all_errors, context_after_second_visit) 116 | } 117 | } 118 | } 119 | 120 | fn set_rule_name_on_errors(errors: List(Error), name: String) -> List(Error) { 121 | list.map(errors, fn(error) { Error(..error, rule: name) }) 122 | } 123 | 124 | fn rule_to_operations( 125 | schema: RuleSchema(context), 126 | errors_and_context: ErrorsAndContext(context), 127 | ) -> ModuleVisitor { 128 | let raise = fn(new_errors_and_context: ErrorsAndContext(context)) { 129 | // Instead of being recursive, this could simply mutate `errors_and_context` 130 | // and return the originally created `ModuleVisitor` below. 131 | rule_to_operations(schema, new_errors_and_context) 132 | } 133 | 134 | ModuleVisitor( 135 | expression_visitor: create_visitor( 136 | schema.name, 137 | raise, 138 | errors_and_context, 139 | schema.expression_visitor, 140 | ), 141 | function_visitor: create_visitor( 142 | schema.name, 143 | raise, 144 | errors_and_context, 145 | schema.function_visitor, 146 | ), 147 | get_errors: fn() { errors_and_context.0 }, 148 | ) 149 | } 150 | 151 | fn create_visitor( 152 | rule_name: String, 153 | raise: fn(ErrorsAndContext(context)) -> a, 154 | errors_and_context: ErrorsAndContext(context), 155 | maybe_visitor: option.Option(fn(b, context) -> ErrorsAndContext(context)), 156 | ) -> option.Option(fn(b) -> a) { 157 | use visitor <- option.map(maybe_visitor) 158 | fn(node) { 159 | raise(accumulate( 160 | rule_name, 161 | fn(context) { visitor(node, context) }, 162 | errors_and_context, 163 | )) 164 | } 165 | } 166 | 167 | /// Concatenate the errors of the previous step and of the last step, and take 168 | /// the last step's context. 169 | /// 170 | fn accumulate( 171 | rule_name: String, 172 | visitor: fn(context) -> ErrorsAndContext(context), 173 | errors_and_context: ErrorsAndContext(context), 174 | ) { 175 | let #(previous_errors, previous_context) = errors_and_context 176 | let #(new_errors, new_context) = visitor(previous_context) 177 | 178 | #( 179 | list.append(set_rule_name_on_errors(new_errors, rule_name), previous_errors), 180 | new_context, 181 | ) 182 | } 183 | 184 | // GETTING A VISITORS OUT OF RULES --------------------------------------------- 185 | 186 | pub fn module_visitor(from rule: Rule) -> ModuleVisitor { 187 | rule.module_visitor() 188 | } 189 | 190 | // RULE ERRORS ----------------------------------------------------------------- 191 | 192 | pub fn error( 193 | message message: String, 194 | details details: List(String), 195 | at location: String, 196 | ) -> Error { 197 | Error( 198 | path: "", 199 | location_identifier: location, 200 | rule: "", 201 | message: message, 202 | details: details, 203 | ) 204 | } 205 | 206 | /// TODO: this could be internal as well. 207 | /// 208 | pub fn set_error_path(error: Error, path: String) -> Error { 209 | Error(..error, path: path) 210 | } 211 | 212 | /// TODO: Just an initial repr for testing, someone good at making things pretty 213 | /// will need to update this. 214 | /// 215 | pub fn pretty_print_error(error: Error) -> String { 216 | let Error( 217 | path: path, 218 | location_identifier: location_identifier, 219 | rule: rule, 220 | message: message, 221 | details: details, 222 | ) = error 223 | 224 | [ 225 | "Path: " <> path <> "\n", 226 | "Location Identifier: " <> location_identifier, 227 | "Rule: " <> rule, 228 | "Error: " <> message, 229 | "Details: " <> string.join(details, with: "\n"), 230 | ] 231 | |> string.join(with: "\n") 232 | } 233 | -------------------------------------------------------------------------------- /src/code_review/rules/no_deprecated.gleam: -------------------------------------------------------------------------------- 1 | import code_review/rule.{type Rule} 2 | import glance 3 | import gleam/list 4 | import gleam/set.{type Set} 5 | 6 | pub fn rule() -> Rule { 7 | rule.new("no_deprecated", initial_context()) 8 | |> rule.with_function_visitor(function_visitor) 9 | |> rule.with_expression_visitor(expression_visitor) 10 | |> rule.to_rule 11 | } 12 | 13 | type Context { 14 | Context(deprecated_functions: Set(String), current_location: String) 15 | } 16 | 17 | fn initial_context() { 18 | Context(deprecated_functions: set.new(), current_location: "") 19 | } 20 | 21 | fn function_visitor( 22 | function: glance.Definition(glance.Function), 23 | context: Context, 24 | ) -> #(List(never), Context) { 25 | let glance.Definition(attributes, func) = function 26 | let is_deprecated = 27 | list.any(attributes, fn(attribute) { attribute.name == "deprecated" }) 28 | let deprecated_functions = case is_deprecated { 29 | True -> set.insert(context.deprecated_functions, func.name) 30 | False -> context.deprecated_functions 31 | } 32 | 33 | #( 34 | [], 35 | Context( 36 | deprecated_functions: deprecated_functions, 37 | current_location: func.name, 38 | ), 39 | ) 40 | } 41 | 42 | fn expression_visitor( 43 | expr: glance.Expression, 44 | context: Context, 45 | ) -> #(List(rule.Error), Context) { 46 | case expr { 47 | glance.Variable(name) -> 48 | case set.contains(context.deprecated_functions, name) { 49 | True -> #( 50 | [ 51 | rule.error( 52 | message: "Found usage of deprecated function", 53 | details: ["Don't use this anymore."], 54 | at: context.current_location, 55 | ), 56 | ], 57 | context, 58 | ) 59 | False -> #([], context) 60 | } 61 | _ -> #([], context) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/code_review/rules/no_panic.gleam: -------------------------------------------------------------------------------- 1 | import code_review/rule.{type Rule} 2 | import glance 3 | 4 | pub fn rule() -> Rule { 5 | rule.new("no_panic", initial_context) 6 | |> rule.with_function_visitor(function_visitor) 7 | |> rule.with_expression_visitor(expression_visitor) 8 | |> rule.to_rule 9 | } 10 | 11 | type Context { 12 | Context(current_location: String) 13 | } 14 | 15 | const initial_context: Context = Context(current_location: "") 16 | 17 | fn function_visitor( 18 | function: glance.Definition(glance.Function), 19 | _: Context, 20 | ) -> #(List(never), Context) { 21 | let glance.Definition(_, func) = function 22 | #([], Context(current_location: func.name)) 23 | } 24 | 25 | fn expression_visitor( 26 | expr: glance.Expression, 27 | context: Context, 28 | ) -> #(List(rule.Error), Context) { 29 | case expr { 30 | glance.Panic(_) -> #( 31 | [ 32 | rule.error( 33 | at: context.current_location, 34 | message: "Found `panic`", 35 | details: [ 36 | "This keyword should almost never be used! It may be useful in initial prototypes and scripts, but its use in a library or production application is a sign that the design could be improved.", 37 | "With well designed types the type system can typically be used to make these invalid states unrepresentable.", 38 | ], 39 | ), 40 | ], 41 | context, 42 | ) 43 | 44 | _ -> #([], context) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/code_review/rules/no_trailing_underscore.gleam: -------------------------------------------------------------------------------- 1 | import code_review/rule.{type Rule} 2 | import glance 3 | import gleam/string 4 | 5 | pub fn rule() -> Rule { 6 | rule.new("no_trailing_underscore", Nil) 7 | |> rule.with_simple_function_visitor(function_visitor) 8 | |> rule.to_rule 9 | } 10 | 11 | pub fn function_visitor( 12 | function: glance.Definition(glance.Function), 13 | ) -> List(rule.Error) { 14 | let glance.Definition(_, func) = function 15 | case string.ends_with(func.name, "_") { 16 | True -> [ 17 | rule.error( 18 | message: "Trailing underscore in function name", 19 | details: ["We don't like no trailing underscores."], 20 | at: func.name, 21 | ), 22 | ] 23 | False -> [] 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/code_review/rules/no_unnecessary_string_concatenation.gleam: -------------------------------------------------------------------------------- 1 | import code_review/rule.{type Rule} 2 | import glance 3 | 4 | pub fn rule() -> Rule { 5 | rule.new("no_unnecessary_string_concatenation", initial_context) 6 | |> rule.with_function_visitor(function_visitor) 7 | |> rule.with_expression_visitor(expression_visitor) 8 | |> rule.to_rule 9 | } 10 | 11 | type Context { 12 | Context(current_location: String) 13 | } 14 | 15 | const initial_context: Context = Context(current_location: "") 16 | 17 | fn function_visitor( 18 | function: glance.Definition(glance.Function), 19 | _: Context, 20 | ) -> #(List(never), Context) { 21 | let glance.Definition(_, func) = function 22 | #([], Context(current_location: func.name)) 23 | } 24 | 25 | fn expression_visitor( 26 | expr: glance.Expression, 27 | context: Context, 28 | ) -> #(List(rule.Error), Context) { 29 | case expr { 30 | glance.BinaryOperator(glance.Concatenate, glance.String(""), _) 31 | | glance.BinaryOperator(glance.Concatenate, _, glance.String("")) -> #( 32 | [ 33 | rule.error( 34 | at: context.current_location, 35 | message: "Unnecessary concatenation with an empty string", 36 | details: [ 37 | "The result of adding an empty string to an expression is the expression itself.", 38 | "You can remove the concatenation with \"\".", 39 | ], 40 | ), 41 | ], 42 | context, 43 | ) 44 | 45 | glance.BinaryOperator( 46 | glance.Concatenate, 47 | glance.String(_), 48 | glance.String(_), 49 | ) -> #( 50 | [ 51 | rule.error( 52 | at: context.current_location, 53 | message: "Unnecessary concatenation of string literals", 54 | details: [ 55 | "Instead of concatenating these two string literals, they can be written as a single one.", 56 | "For instance, instead of \"a\" <> \"b\", you could write that as \"ab\".", 57 | ], 58 | ), 59 | ], 60 | context, 61 | ) 62 | 63 | _ -> #([], context) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/code_review/setup.gleam: -------------------------------------------------------------------------------- 1 | import filepath 2 | import simplifile 3 | 4 | const review_file_name = "review.gleam" 5 | 6 | const init_setup_src = " 7 | import code_review 8 | import code_review/rules/no_panic 9 | import code_review/rules/no_unnecessary_string_concatenation 10 | import code_review/rules/no_trailing_underscore 11 | import code_review/rules/no_deprecated 12 | 13 | pub fn main() { 14 | let rules = [ 15 | no_panic.rule(), 16 | no_unnecessary_string_concatenation.rule(), 17 | no_trailing_underscore.rule(), 18 | no_deprecated.rule(), 19 | ] 20 | code_review.run(rules) 21 | } 22 | " 23 | 24 | pub fn main() { 25 | let assert Ok(curr_dir) = simplifile.current_directory() 26 | let assert Ok(_) = 27 | simplifile.write( 28 | filepath.join(curr_dir, "test/" <> review_file_name), 29 | init_setup_src, 30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /test/code_review_test.gleam: -------------------------------------------------------------------------------- 1 | import birdie 2 | import code_review 3 | import code_review/internal/project 4 | import code_review/rule 5 | import code_review/rules/no_deprecated 6 | import code_review/rules/no_panic 7 | import code_review/rules/no_trailing_underscore 8 | import code_review/rules/no_unnecessary_string_concatenation 9 | import glance 10 | import gleam/dict 11 | import gleam/list 12 | import gleam/string 13 | import gleeunit 14 | 15 | pub fn main() { 16 | gleeunit.main() 17 | } 18 | 19 | fn test_example_source_no_gleam_toml(example_code src: String) -> String { 20 | let assert Ok(module) = glance.module(src) 21 | let project = 22 | project.Project(config: dict.new(), src_modules: [ 23 | project.Module(name: "mocked", src: module), 24 | ]) 25 | 26 | let rules = [ 27 | no_panic.rule(), 28 | no_unnecessary_string_concatenation.rule(), 29 | no_trailing_underscore.rule(), 30 | no_deprecated.rule(), 31 | ] 32 | 33 | code_review.visit(project, rules) 34 | |> list.map(rule.pretty_print_error) 35 | |> string.join(with: "\n\n\n") 36 | } 37 | 38 | pub fn no_deprecated_test() { 39 | " 40 | pub fn main() { 41 | old_function() 42 | new_function() 43 | } 44 | 45 | @deprecated(\"Use new_function instead\") 46 | fn old_function() { 47 | Nil 48 | } 49 | 50 | fn new_function() { 51 | Nil 52 | }" 53 | |> test_example_source_no_gleam_toml 54 | |> birdie.snap("No Deprecated Functions Used") 55 | } 56 | 57 | pub fn basic_panic_test() { 58 | " 59 | pub fn this_code_panics() { 60 | panic as \"I freakin panic bro\" 61 | }" 62 | |> test_example_source_no_gleam_toml 63 | |> birdie.snap("No Panic Test") 64 | } 65 | 66 | pub fn no_trailing_underscore_test() { 67 | " 68 | pub fn with_trailing_() { 69 | 1 70 | } 71 | 72 | pub fn without_trailing() { 73 | 1 74 | } 75 | " 76 | |> test_example_source_no_gleam_toml 77 | |> birdie.snap("No Trailing Underscore Test") 78 | } 79 | 80 | pub fn no_panic_inside_use_call_test() { 81 | " 82 | import gleam/bool 83 | 84 | pub fn panic_in_use_call() { 85 | use <- bool.guard(True, panic as \"oops\") 86 | Nil 87 | } 88 | " 89 | |> test_example_source_no_gleam_toml 90 | |> birdie.snap("No Panic Inside Use Call") 91 | } 92 | 93 | pub fn no_panic_inside_use_test() { 94 | " 95 | import gleam/bool 96 | 97 | pub fn panic_inside_use() { 98 | use <- bool.guard(False, Nil) 99 | let _ = Nil 100 | panic as \"panic inside use\" 101 | } 102 | " 103 | |> test_example_source_no_gleam_toml 104 | |> birdie.snap("No Panic Inside Use") 105 | } 106 | 107 | pub fn no_unnecessary_empty_string_concatenation_test() { 108 | " 109 | pub fn concat_empty(a: String, b: String) { 110 | let _unused = a <> \"\" 111 | \"\" <> b 112 | } 113 | " 114 | |> test_example_source_no_gleam_toml 115 | |> birdie.snap("No Unnecessary Empty String Concatenation Test") 116 | } 117 | 118 | pub fn no_unnecessary_string_concatenation_test() { 119 | " 120 | pub fn string_concatenation() { 121 | \"a\" <> \"b\" 122 | } 123 | 124 | pub fn no_string_concatenation_to_report(var: String) { 125 | \"a\" <> var 126 | } 127 | " 128 | |> test_example_source_no_gleam_toml 129 | |> birdie.snap("No Unnecessary String Concatenation Test") 130 | } 131 | --------------------------------------------------------------------------------