|
| 1 | +use ast = "pony_compiler" |
| 2 | + |
| 3 | +primitive MethodDeclarationFormat is ASTRule |
| 4 | + """ |
| 5 | + Checks formatting of multiline method declarations per the style guide. |
| 6 | +
|
| 7 | + When a method's parameters span multiple lines, checks: |
| 8 | + - Each parameter is on its own line (no two parameters sharing a line). |
| 9 | +
|
| 10 | + When a method declaration spans multiple lines, checks: |
| 11 | + - The return type ':' is at the method keyword's column + 2 (one indent |
| 12 | + level deeper) when it appears as the first non-whitespace on its line. |
| 13 | + - The '=>' is at the method keyword's column when it appears as the first |
| 14 | + non-whitespace on its line. |
| 15 | +
|
| 16 | + Single-line method declarations are not checked. |
| 17 | + """ |
| 18 | + fun id(): String val => "style/method-declaration-format" |
| 19 | + fun category(): String val => "style" |
| 20 | + |
| 21 | + fun description(): String val => |
| 22 | + "multiline method declaration formatting" |
| 23 | + + " (parameter layout, return type and '=>' alignment)" |
| 24 | + |
| 25 | + fun default_status(): RuleStatus => RuleOn |
| 26 | + |
| 27 | + fun node_filter(): Array[ast.TokenId] val => |
| 28 | + [ ast.TokenIds.tk_fun() |
| 29 | + ast.TokenIds.tk_new() |
| 30 | + ast.TokenIds.tk_be() ] |
| 31 | + |
| 32 | + fun check(node: ast.AST box, source: SourceFile val) |
| 33 | + : Array[Diagnostic val] val |
| 34 | + => |
| 35 | + """ |
| 36 | + Check formatting of a multiline method declaration. |
| 37 | + """ |
| 38 | + let keyword_line = node.line() |
| 39 | + let keyword_col = node.pos() |
| 40 | + |
| 41 | + // Determine the keyword name for diagnostic messages |
| 42 | + let keyword_name = _keyword_name(node.id()) |
| 43 | + |
| 44 | + let diags = recover iso Array[Diagnostic val] end |
| 45 | + |
| 46 | + // Check params (child 3) |
| 47 | + try |
| 48 | + let params = node(3)? |
| 49 | + if params.id() != ast.TokenIds.tk_none() then |
| 50 | + let num_params = params.num_children() |
| 51 | + if num_params > 1 then |
| 52 | + // Find line of first and last param to determine if multiline |
| 53 | + try |
| 54 | + let first_param = params(0)? |
| 55 | + let first_line = _param_line(first_param) |
| 56 | + let last_line = _last_param_line(params) |
| 57 | + if first_line != last_line then |
| 58 | + // Multiline params: check each is on its own line |
| 59 | + var prev_line = first_line |
| 60 | + var i: USize = 1 |
| 61 | + while i < num_params do |
| 62 | + let param_node = params(i)? |
| 63 | + let param_l = _param_line(param_node) |
| 64 | + if param_l == prev_line then |
| 65 | + diags.push(Diagnostic(id(), |
| 66 | + "each parameter should be on its own line" |
| 67 | + + " in a multiline declaration", |
| 68 | + source.rel_path, param_l, param_node.pos())) |
| 69 | + end |
| 70 | + prev_line = param_l |
| 71 | + i = i + 1 |
| 72 | + end |
| 73 | + end |
| 74 | + end |
| 75 | + end |
| 76 | + end |
| 77 | + end |
| 78 | + |
| 79 | + // Check return type ':' alignment (child 4) |
| 80 | + try |
| 81 | + let ret_type = node(4)? |
| 82 | + if ret_type.id() != ast.TokenIds.tk_none() then |
| 83 | + let ret_line = ret_type.line() |
| 84 | + if ret_line > keyword_line then |
| 85 | + try |
| 86 | + let line_text = source.lines(ret_line - 1)? |
| 87 | + (let first_ch, let first_col) = _first_nonws_char(line_text) |
| 88 | + if first_ch == ':' then |
| 89 | + let expected_col = keyword_col + 2 |
| 90 | + let actual_col = first_col + 1 // convert 0-based to 1-based |
| 91 | + if actual_col != expected_col then |
| 92 | + diags.push(Diagnostic(id(), |
| 93 | + "':' should align at column " |
| 94 | + + expected_col.string() |
| 95 | + + " (indented from '" + keyword_name + "')", |
| 96 | + source.rel_path, ret_line, actual_col)) |
| 97 | + end |
| 98 | + end |
| 99 | + end |
| 100 | + end |
| 101 | + end |
| 102 | + end |
| 103 | + |
| 104 | + // Check '=>' alignment (child 6 = body) |
| 105 | + try |
| 106 | + let body = node(6)? |
| 107 | + if body.id() != ast.TokenIds.tk_none() then |
| 108 | + let body_line = body.line() |
| 109 | + if body_line > keyword_line then |
| 110 | + // Scan backward from the line before the body to find '=>' |
| 111 | + var scan_line = body_line - 1 |
| 112 | + while scan_line > keyword_line do |
| 113 | + try |
| 114 | + let line_text = source.lines(scan_line - 1)? |
| 115 | + (let word, let word_col) = |
| 116 | + _first_nonws_word(line_text) |
| 117 | + if word == "=>" then |
| 118 | + let actual_col = word_col |
| 119 | + if actual_col != keyword_col then |
| 120 | + diags.push(Diagnostic(id(), |
| 121 | + "'=>' should align with '" + keyword_name |
| 122 | + + "' keyword (column " |
| 123 | + + keyword_col.string() + ")", |
| 124 | + source.rel_path, scan_line, actual_col)) |
| 125 | + end |
| 126 | + break |
| 127 | + end |
| 128 | + // Stop scanning if we hit a non-blank line that isn't '=>' |
| 129 | + if word.size() > 0 then break end |
| 130 | + end |
| 131 | + scan_line = scan_line - 1 |
| 132 | + end |
| 133 | + end |
| 134 | + end |
| 135 | + end |
| 136 | + |
| 137 | + consume diags |
| 138 | + |
| 139 | + fun _keyword_name(token_id: ast.TokenId): String val => |
| 140 | + """ |
| 141 | + Return the source keyword for the given method token type. |
| 142 | + """ |
| 143 | + if token_id == ast.TokenIds.tk_fun() then "fun" |
| 144 | + elseif token_id == ast.TokenIds.tk_new() then "new" |
| 145 | + elseif token_id == ast.TokenIds.tk_be() then "be" |
| 146 | + else "unknown" |
| 147 | + end |
| 148 | + |
| 149 | + fun _param_line(param: ast.AST box): USize => |
| 150 | + """ |
| 151 | + Get the line number of a parameter node. Since TK_PARAM is abstract |
| 152 | + (no source position), use the first child's line (the parameter name |
| 153 | + or dontcare). |
| 154 | + """ |
| 155 | + try param(0)?.line() |
| 156 | + else param.line() |
| 157 | + end |
| 158 | + |
| 159 | + fun _last_param_line(params: ast.AST box): USize => |
| 160 | + """ |
| 161 | + Find the maximum line number across all parameter children. |
| 162 | + """ |
| 163 | + var max_line: USize = 0 |
| 164 | + var i: USize = 0 |
| 165 | + let count = params.num_children() |
| 166 | + while i < count do |
| 167 | + try |
| 168 | + let p = params(i)? |
| 169 | + let l = _param_line(p) |
| 170 | + if l > max_line then max_line = l end |
| 171 | + end |
| 172 | + i = i + 1 |
| 173 | + end |
| 174 | + max_line |
| 175 | + |
| 176 | + fun _first_nonws_char(line: String val): (U8, USize) => |
| 177 | + """ |
| 178 | + Find the first non-whitespace character on a line. |
| 179 | + Returns `(char, 0-based-index)` or `(0, 0)` if blank. |
| 180 | + """ |
| 181 | + var i: USize = 0 |
| 182 | + while i < line.size() do |
| 183 | + try |
| 184 | + let ch = line(i)? |
| 185 | + if (ch != ' ') and (ch != '\t') then |
| 186 | + return (ch, i) |
| 187 | + end |
| 188 | + end |
| 189 | + i = i + 1 |
| 190 | + end |
| 191 | + (0, 0) |
| 192 | + |
| 193 | + fun _first_nonws_word(line: String val): (String val, USize) => |
| 194 | + """ |
| 195 | + Extract the first non-whitespace word from a line, returning the word |
| 196 | + and its 1-based column position. Returns `("", 0)` for blank lines. |
| 197 | + """ |
| 198 | + var i: USize = 0 |
| 199 | + while i < line.size() do |
| 200 | + try |
| 201 | + let ch = line(i)? |
| 202 | + if (ch != ' ') and (ch != '\t') then break end |
| 203 | + end |
| 204 | + i = i + 1 |
| 205 | + end |
| 206 | + |
| 207 | + if i >= line.size() then |
| 208 | + return ("", 0) |
| 209 | + end |
| 210 | + |
| 211 | + let col = i + 1 // 1-based |
| 212 | + let start = i |
| 213 | + |
| 214 | + while i < line.size() do |
| 215 | + try |
| 216 | + let ch = line(i)? |
| 217 | + if (ch == ' ') or (ch == '\t') then break end |
| 218 | + end |
| 219 | + i = i + 1 |
| 220 | + end |
| 221 | + |
| 222 | + (recover val line.substring(ISize.from[USize](start), |
| 223 | + ISize.from[USize](i)) end, col) |
0 commit comments