diff --git a/tools/pony-lint/glob_match.pony b/tools/pony-lint/glob_match.pony index 7796b4c6ea..a82c74f830 100644 --- a/tools/pony-lint/glob_match.pony +++ b/tools/pony-lint/glob_match.pony @@ -18,8 +18,12 @@ primitive GlobMatch _matches(pattern, 0, pattern.size(), text, 0, text.size()) fun _matches( - p: String val, ps: USize, pe: USize, - t: String val, ts: USize, te: USize) + p: String val, + ps: USize, + pe: USize, + t: String val, + ts: USize, + te: USize) : Bool => """ @@ -100,8 +104,12 @@ primitive GlobMatch _match_segment(p, ps, pe, t, ts, te) fun _match_leading_star( - p: String val, ps: USize, pe: USize, - t: String val, ts: USize, te: USize) + p: String val, + ps: USize, + pe: USize, + t: String val, + ts: USize, + te: USize) : Bool => """ @@ -119,8 +127,12 @@ primitive GlobMatch false fun _match_segment( - p: String val, ps: USize, pe: USize, - t: String val, ts: USize, te: USize) + p: String val, + ps: USize, + pe: USize, + t: String val, + ts: USize, + te: USize) : Bool => """ diff --git a/tools/pony-lint/main.pony b/tools/pony-lint/main.pony index 233df9d582..4daa34ea1a 100644 --- a/tools/pony-lint/main.pony +++ b/tools/pony-lint/main.pony @@ -93,6 +93,8 @@ actor Main .> push(ControlStructureAlignment) .> push(TypeAliasFormat) .> push(ArrayLiteralFormat) + .> push(MethodDeclarationFormat) + .> push(TypeParameterFormat) end // Handle --explain diff --git a/tools/pony-lint/method_declaration_format.pony b/tools/pony-lint/method_declaration_format.pony new file mode 100644 index 0000000000..33928e11b2 --- /dev/null +++ b/tools/pony-lint/method_declaration_format.pony @@ -0,0 +1,223 @@ +use ast = "pony_compiler" + +primitive MethodDeclarationFormat is ASTRule + """ + Checks formatting of multiline method declarations per the style guide. + + When a method's parameters span multiple lines, checks: + - Each parameter is on its own line (no two parameters sharing a line). + + When a method declaration spans multiple lines, checks: + - The return type ':' is at the method keyword's column + 2 (one indent + level deeper) when it appears as the first non-whitespace on its line. + - The '=>' is at the method keyword's column when it appears as the first + non-whitespace on its line. + + Single-line method declarations are not checked. + """ + fun id(): String val => "style/method-declaration-format" + fun category(): String val => "style" + + fun description(): String val => + "multiline method declaration formatting" + + " (parameter layout, return type and '=>' alignment)" + + fun default_status(): RuleStatus => RuleOn + + fun node_filter(): Array[ast.TokenId] val => + [ ast.TokenIds.tk_fun() + ast.TokenIds.tk_new() + ast.TokenIds.tk_be() ] + + fun check(node: ast.AST box, source: SourceFile val) + : Array[Diagnostic val] val + => + """ + Check formatting of a multiline method declaration. + """ + let keyword_line = node.line() + let keyword_col = node.pos() + + // Determine the keyword name for diagnostic messages + let keyword_name = _keyword_name(node.id()) + + let diags = recover iso Array[Diagnostic val] end + + // Check params (child 3) + try + let params = node(3)? + if params.id() != ast.TokenIds.tk_none() then + let num_params = params.num_children() + if num_params > 1 then + // Find line of first and last param to determine if multiline + try + let first_param = params(0)? + let first_line = _param_line(first_param) + let last_line = _last_param_line(params) + if first_line != last_line then + // Multiline params: check each is on its own line + var prev_line = first_line + var i: USize = 1 + while i < num_params do + let param_node = params(i)? + let param_l = _param_line(param_node) + if param_l == prev_line then + diags.push(Diagnostic(id(), + "each parameter should be on its own line" + + " in a multiline declaration", + source.rel_path, param_l, param_node.pos())) + end + prev_line = param_l + i = i + 1 + end + end + end + end + end + end + + // Check return type ':' alignment (child 4) + try + let ret_type = node(4)? + if ret_type.id() != ast.TokenIds.tk_none() then + let ret_line = ret_type.line() + if ret_line > keyword_line then + try + let line_text = source.lines(ret_line - 1)? + (let first_ch, let first_col) = _first_nonws_char(line_text) + if first_ch == ':' then + let expected_col = keyword_col + 2 + let actual_col = first_col + 1 // convert 0-based to 1-based + if actual_col != expected_col then + diags.push(Diagnostic(id(), + "':' should align at column " + + expected_col.string() + + " (indented from '" + keyword_name + "')", + source.rel_path, ret_line, actual_col)) + end + end + end + end + end + end + + // Check '=>' alignment (child 6 = body) + try + let body = node(6)? + if body.id() != ast.TokenIds.tk_none() then + let body_line = body.line() + if body_line > keyword_line then + // Scan backward from the line before the body to find '=>' + var scan_line = body_line - 1 + while scan_line > keyword_line do + try + let line_text = source.lines(scan_line - 1)? + (let word, let word_col) = + _first_nonws_word(line_text) + if word == "=>" then + let actual_col = word_col + if actual_col != keyword_col then + diags.push(Diagnostic(id(), + "'=>' should align with '" + keyword_name + + "' keyword (column " + + keyword_col.string() + ")", + source.rel_path, scan_line, actual_col)) + end + break + end + // Stop scanning if we hit a non-blank line that isn't '=>' + if word.size() > 0 then break end + end + scan_line = scan_line - 1 + end + end + end + end + + consume diags + + fun _keyword_name(token_id: ast.TokenId): String val => + """ + Return the source keyword for the given method token type. + """ + if token_id == ast.TokenIds.tk_fun() then "fun" + elseif token_id == ast.TokenIds.tk_new() then "new" + elseif token_id == ast.TokenIds.tk_be() then "be" + else "unknown" + end + + fun _param_line(param: ast.AST box): USize => + """ + Get the line number of a parameter node. Since TK_PARAM is abstract + (no source position), use the first child's line (the parameter name + or dontcare). + """ + try param(0)?.line() + else param.line() + end + + fun _last_param_line(params: ast.AST box): USize => + """ + Find the maximum line number across all parameter children. + """ + var max_line: USize = 0 + var i: USize = 0 + let count = params.num_children() + while i < count do + try + let p = params(i)? + let l = _param_line(p) + if l > max_line then max_line = l end + end + i = i + 1 + end + max_line + + fun _first_nonws_char(line: String val): (U8, USize) => + """ + Find the first non-whitespace character on a line. + Returns `(char, 0-based-index)` or `(0, 0)` if blank. + """ + var i: USize = 0 + while i < line.size() do + try + let ch = line(i)? + if (ch != ' ') and (ch != '\t') then + return (ch, i) + end + end + i = i + 1 + end + (0, 0) + + fun _first_nonws_word(line: String val): (String val, USize) => + """ + Extract the first non-whitespace word from a line, returning the word + and its 1-based column position. Returns `("", 0)` for blank lines. + """ + var i: USize = 0 + while i < line.size() do + try + let ch = line(i)? + if (ch != ' ') and (ch != '\t') then break end + end + i = i + 1 + end + + if i >= line.size() then + return ("", 0) + end + + let col = i + 1 // 1-based + let start = i + + while i < line.size() do + try + let ch = line(i)? + if (ch == ' ') or (ch == '\t') then break end + end + i = i + 1 + end + + (recover val line.substring(ISize.from[USize](start), + ISize.from[USize](i)) end, col) diff --git a/tools/pony-lint/test/_ast_test_helper.pony b/tools/pony-lint/test/_ast_test_helper.pony index 0cfe48dfbc..ff251643bb 100644 --- a/tools/pony-lint/test/_ast_test_helper.pony +++ b/tools/pony-lint/test/_ast_test_helper.pony @@ -16,7 +16,9 @@ primitive \nodoc\ _ASTTestHelper directory, not an installed location, so executable-relative discovery is not used here. """ - fun compile(h: TestHelper, source: String val, + fun compile( + h: TestHelper, + source: String val, filename: String val = "test.pony") : (ast.Program val, lint.SourceFile val) ? => diff --git a/tools/pony-lint/test/_test_method_declaration_format.pony b/tools/pony-lint/test/_test_method_declaration_format.pony new file mode 100644 index 0000000000..41e5b71f3e --- /dev/null +++ b/tools/pony-lint/test/_test_method_declaration_format.pony @@ -0,0 +1,322 @@ +use "pony_test" +use ast = "pony_compiler" +use lint = ".." + +class \nodoc\ _TestMethodDeclFormatSingleLine is UnitTest + """Single-line method declaration is skipped (no diagnostics).""" + fun name(): String => "MethodDeclarationFormat: single-line skipped" + fun exclusion_group(): String => "ast-compile" + + fun apply(h: TestHelper) => + let source: String val = + "class Foo\n" + + " fun apply(x: U32, y: U32): U32 => x + y\n" + try + (let program, let sf) = _ASTTestHelper.compile(h, source)? + match program.package() + | let pkg: ast.Package val => + match pkg.module() + | let mod: ast.Module val => + let diags = + _CollectRuleDiags(mod, sf, lint.MethodDeclarationFormat) + h.assert_eq[USize](0, diags.size()) + else + h.fail("no module") + end + else + h.fail("no package") + end + else + h.fail("compilation failed") + end + +class \nodoc\ _TestMethodDeclFormatMultiLineFunClean is UnitTest + """Multiline fun with each param on own line, correct alignment.""" + fun name(): String => "MethodDeclarationFormat: multiline fun clean" + fun exclusion_group(): String => "ast-compile" + + fun apply(h: TestHelper) => + let source: String val = + "class Foo\n" + + " fun find(\n" + + " value: U32,\n" + + " offset: USize)\n" + + " : USize\n" + + " =>\n" + + " offset\n" + try + (let program, let sf) = _ASTTestHelper.compile(h, source)? + match program.package() + | let pkg: ast.Package val => + match pkg.module() + | let mod: ast.Module val => + let diags = + _CollectRuleDiags(mod, sf, lint.MethodDeclarationFormat) + h.assert_eq[USize](0, diags.size()) + else + h.fail("no module") + end + else + h.fail("no package") + end + else + h.fail("compilation failed") + end + +class \nodoc\ _TestMethodDeclFormatMultiLineNoReturnClean is UnitTest + """Multiline method with no return type (just params and '=>').""" + fun name(): String => "MethodDeclarationFormat: multiline no return clean" + fun exclusion_group(): String => "ast-compile" + + fun apply(h: TestHelper) => + let source: String val = + "class Foo\n" + + " fun apply(\n" + + " x: U32,\n" + + " y: U32)\n" + + " =>\n" + + " None\n" + try + (let program, let sf) = _ASTTestHelper.compile(h, source)? + match program.package() + | let pkg: ast.Package val => + match pkg.module() + | let mod: ast.Module val => + let diags = + _CollectRuleDiags(mod, sf, lint.MethodDeclarationFormat) + h.assert_eq[USize](0, diags.size()) + else + h.fail("no module") + end + else + h.fail("no package") + end + else + h.fail("compilation failed") + end + +class \nodoc\ _TestMethodDeclFormatNewClean is UnitTest + """Multiline new constructor with correct formatting.""" + fun name(): String => "MethodDeclarationFormat: new constructor clean" + fun exclusion_group(): String => "ast-compile" + + fun apply(h: TestHelper) => + let source: String val = + "class Foo\n" + + " let _x: U32\n" + + " let _y: U32\n" + + "\n" + + " new create(\n" + + " x: U32,\n" + + " y: U32)\n" + + " =>\n" + + " _x = x\n" + + " _y = y\n" + try + (let program, let sf) = _ASTTestHelper.compile(h, source)? + match program.package() + | let pkg: ast.Package val => + match pkg.module() + | let mod: ast.Module val => + let diags = + _CollectRuleDiags(mod, sf, lint.MethodDeclarationFormat) + h.assert_eq[USize](0, diags.size()) + else + h.fail("no module") + end + else + h.fail("no package") + end + else + h.fail("compilation failed") + end + +class \nodoc\ _TestMethodDeclFormatBeClean is UnitTest + """Multiline be behavior with correct formatting.""" + fun name(): String => "MethodDeclarationFormat: be behavior clean" + fun exclusion_group(): String => "ast-compile" + + fun apply(h: TestHelper) => + let source: String val = + "actor Foo\n" + + " be apply(\n" + + " x: U32,\n" + + " y: U32)\n" + + " =>\n" + + " None\n" + try + (let program, let sf) = _ASTTestHelper.compile(h, source)? + match program.package() + | let pkg: ast.Package val => + match pkg.module() + | let mod: ast.Module val => + let diags = + _CollectRuleDiags(mod, sf, lint.MethodDeclarationFormat) + h.assert_eq[USize](0, diags.size()) + else + h.fail("no module") + end + else + h.fail("no package") + end + else + h.fail("compilation failed") + end + +class \nodoc\ _TestMethodDeclFormatParamsSharingLine is UnitTest + """Two params sharing a line in multiline declaration produces diagnostic.""" + fun name(): String => "MethodDeclFormat: params sharing line" + fun exclusion_group(): String => "ast-compile" + + fun apply(h: TestHelper) => + let source: String val = + "class Foo\n" + + " fun apply(\n" + + " x: U32, y: U32,\n" + + " z: U32)\n" + + " =>\n" + + " None\n" + try + (let program, let sf) = _ASTTestHelper.compile(h, source)? + match program.package() + | let pkg: ast.Package val => + match pkg.module() + | let mod: ast.Module val => + let diags = + _CollectRuleDiags(mod, sf, lint.MethodDeclarationFormat) + h.assert_eq[USize](1, diags.size(), + "expected 1 diagnostic for params sharing a line") + try + h.assert_true( + diags(0)?.message.contains( + "each parameter should be on its own line"), + "message should mention parameter on own line") + else + h.fail("could not access diagnostic") + end + else + h.fail("no module") + end + else + h.fail("no package") + end + else + h.fail("compilation failed") + end + +class \nodoc\ _TestMethodDeclFormatColonMisaligned is UnitTest + """':' at wrong column on its own line produces diagnostic.""" + fun name(): String => "MethodDeclFormat: ':' misaligned" + fun exclusion_group(): String => "ast-compile" + + fun apply(h: TestHelper) => + let source: String val = + "class Foo\n" + + " fun find(\n" + + " value: U32,\n" + + " offset: USize)\n" + + " : USize\n" + + " =>\n" + + " offset\n" + try + (let program, let sf) = _ASTTestHelper.compile(h, source)? + match program.package() + | let pkg: ast.Package val => + match pkg.module() + | let mod: ast.Module val => + let diags = + _CollectRuleDiags(mod, sf, lint.MethodDeclarationFormat) + h.assert_eq[USize](1, diags.size(), + "expected 1 diagnostic for ':' misalignment") + try + h.assert_true( + diags(0)?.message.contains("':' should align at column"), + "message should mention ':' alignment") + else + h.fail("could not access diagnostic") + end + else + h.fail("no module") + end + else + h.fail("no package") + end + else + h.fail("compilation failed") + end + +class \nodoc\ _TestMethodDeclFormatArrowMisaligned is UnitTest + """'=>' at wrong column on its own line produces diagnostic.""" + fun name(): String => "MethodDeclFormat: '=>' misaligned" + fun exclusion_group(): String => "ast-compile" + + fun apply(h: TestHelper) => + let source: String val = + "class Foo\n" + + " fun find(\n" + + " value: U32,\n" + + " offset: USize)\n" + + " : USize\n" + + " =>\n" + + " offset\n" + try + (let program, let sf) = _ASTTestHelper.compile(h, source)? + match program.package() + | let pkg: ast.Package val => + match pkg.module() + | let mod: ast.Module val => + let diags = + _CollectRuleDiags(mod, sf, lint.MethodDeclarationFormat) + h.assert_eq[USize](1, diags.size(), + "expected 1 diagnostic for '=>' misalignment") + try + h.assert_true( + diags(0)?.message.contains( + "'=>' should align with 'fun' keyword"), + "message should mention '=>' alignment") + else + h.fail("could not access diagnostic") + end + else + h.fail("no module") + end + else + h.fail("no package") + end + else + h.fail("compilation failed") + end + +class \nodoc\ _TestMethodDeclFormatMultipleViolations is UnitTest + """Both ':' and '=>' misaligned produces two diagnostics.""" + fun name(): String => "MethodDeclFormat: multiple violations" + fun exclusion_group(): String => "ast-compile" + + fun apply(h: TestHelper) => + let source: String val = + "class Foo\n" + + " fun find(\n" + + " value: U32,\n" + + " offset: USize)\n" + + " : USize\n" + + " =>\n" + + " offset\n" + try + (let program, let sf) = _ASTTestHelper.compile(h, source)? + match program.package() + | let pkg: ast.Package val => + match pkg.module() + | let mod: ast.Module val => + let diags = + _CollectRuleDiags(mod, sf, lint.MethodDeclarationFormat) + h.assert_eq[USize](2, diags.size(), + "expected 2 diagnostics for ':' and '=>' misalignment") + else + h.fail("no module") + end + else + h.fail("no package") + end + else + h.fail("compilation failed") + end diff --git a/tools/pony-lint/test/_test_type_naming.pony b/tools/pony-lint/test/_test_type_naming.pony index 9da493d461..153485e1c5 100644 --- a/tools/pony-lint/test/_test_type_naming.pony +++ b/tools/pony-lint/test/_test_type_naming.pony @@ -117,8 +117,11 @@ class \nodoc\ _TestTypeNamingAllEntityTypes is UnitTest primitive \nodoc\ _CollectRuleDiags """Helper: walk a module's AST and collect diagnostics from a single rule.""" - fun apply(mod: ast.Module val, sf: lint.SourceFile val, - rule: lint.ASTRule val): Array[lint.Diagnostic val] val + fun apply( + mod: ast.Module val, + sf: lint.SourceFile val, + rule: lint.ASTRule val) + : Array[lint.Diagnostic val] val => let collector = _RuleCollector(rule, sf) mod.ast.visit(collector) diff --git a/tools/pony-lint/test/_test_type_parameter_format.pony b/tools/pony-lint/test/_test_type_parameter_format.pony new file mode 100644 index 0000000000..c7101fa3af --- /dev/null +++ b/tools/pony-lint/test/_test_type_parameter_format.pony @@ -0,0 +1,269 @@ +use "pony_test" +use ast = "pony_compiler" +use lint = ".." + +class \nodoc\ _TestTypeParamFormatSingleLine is UnitTest + """Single-line type params produce no diagnostics.""" + fun name(): String => "TypeParameterFormat: single-line clean" + fun exclusion_group(): String => "ast-compile" + + fun apply(h: TestHelper) => + let source: String val = + "class Foo[A, B]\n" + try + (let program, let sf) = _ASTTestHelper.compile(h, source)? + match program.package() + | let pkg: ast.Package val => + match pkg.module() + | let mod: ast.Module val => + let diags = + _CollectRuleDiags(mod, sf, lint.TypeParameterFormat) + h.assert_eq[USize](0, diags.size()) + else + h.fail("no module") + end + else + h.fail("no package") + end + else + h.fail("compilation failed") + end + +class \nodoc\ _TestTypeParamFormatMultiLineClassClean is UnitTest + """Multiline type params each on own line (class) produces no diagnostics.""" + fun name(): String => "TypeParameterFormat: multiline class clean" + fun exclusion_group(): String => "ast-compile" + + fun apply(h: TestHelper) => + let source: String val = + "class Foo[\n" + + " A,\n" + + " B]\n" + try + (let program, let sf) = _ASTTestHelper.compile(h, source)? + match program.package() + | let pkg: ast.Package val => + match pkg.module() + | let mod: ast.Module val => + let diags = + _CollectRuleDiags(mod, sf, lint.TypeParameterFormat) + h.assert_eq[USize](0, diags.size()) + else + h.fail("no module") + end + else + h.fail("no package") + end + else + h.fail("compilation failed") + end + +class \nodoc\ _TestTypeParamFormatTraitIsClean is UnitTest + """Trait with multiline type params and properly aligned 'is'.""" + fun name(): String => "TypeParameterFormat: trait with 'is' clean" + fun exclusion_group(): String => "ast-compile" + + fun apply(h: TestHelper) => + let source: String val = + "interface Hashable\n" + + "trait Foo[\n" + + " A,\n" + + " B]\n" + + " is Hashable\n" + try + (let program, let sf) = _ASTTestHelper.compile(h, source)? + match program.package() + | let pkg: ast.Package val => + match pkg.module() + | let mod: ast.Module val => + let diags = + _CollectRuleDiags(mod, sf, lint.TypeParameterFormat) + h.assert_eq[USize](0, diags.size()) + else + h.fail("no module") + end + else + h.fail("no package") + end + else + h.fail("compilation failed") + end + +class \nodoc\ _TestTypeParamFormatMethodClean is UnitTest + """Method with multiline type params produces no diagnostics.""" + fun name(): String => "TypeParameterFormat: method type params clean" + fun exclusion_group(): String => "ast-compile" + + fun apply(h: TestHelper) => + let source: String val = + "class Foo\n" + + " fun bar[\n" + + " A,\n" + + " B](x: A)\n" + + " =>\n" + + " None\n" + try + (let program, let sf) = _ASTTestHelper.compile(h, source)? + match program.package() + | let pkg: ast.Package val => + match pkg.module() + | let mod: ast.Module val => + let diags = + _CollectRuleDiags(mod, sf, lint.TypeParameterFormat) + h.assert_eq[USize](0, diags.size()) + else + h.fail("no module") + end + else + h.fail("no package") + end + else + h.fail("compilation failed") + end + +class \nodoc\ _TestTypeParamFormatBracketWrongLine is UnitTest + """'[' on different line than name produces diagnostic.""" + fun name(): String => "TypeParameterFormat: '[' wrong line flagged" + fun exclusion_group(): String => "ast-compile" + + fun apply(h: TestHelper) => + let source: String val = + "class Foo\n" + + " [A, B]\n" + try + (let program, let sf) = _ASTTestHelper.compile(h, source)? + match program.package() + | let pkg: ast.Package val => + match pkg.module() + | let mod: ast.Module val => + let diags = + _CollectRuleDiags(mod, sf, lint.TypeParameterFormat) + h.assert_eq[USize](1, diags.size(), + "expected 1 diagnostic for '[' on wrong line") + try + h.assert_true( + diags(0)?.message.contains( + "'[' should be on the same line"), + "message should mention '[' same line") + else + h.fail("could not access diagnostic") + end + else + h.fail("no module") + end + else + h.fail("no package") + end + else + h.fail("compilation failed") + end + +class \nodoc\ _TestTypeParamFormatSharingLine is UnitTest + """Two type params sharing a line in multiline declaration.""" + fun name(): String => "TypeParamFormat: params sharing line" + fun exclusion_group(): String => "ast-compile" + + fun apply(h: TestHelper) => + let source: String val = + "class Foo[\n" + + " A, B,\n" + + " C]\n" + try + (let program, let sf) = _ASTTestHelper.compile(h, source)? + match program.package() + | let pkg: ast.Package val => + match pkg.module() + | let mod: ast.Module val => + let diags = + _CollectRuleDiags(mod, sf, lint.TypeParameterFormat) + h.assert_eq[USize](1, diags.size(), + "expected 1 diagnostic for type params sharing a line") + try + h.assert_true( + diags(0)?.message.contains( + "each type parameter should be on its own line"), + "message should mention type param on own line") + else + h.fail("could not access diagnostic") + end + else + h.fail("no module") + end + else + h.fail("no package") + end + else + h.fail("compilation failed") + end + +class \nodoc\ _TestTypeParamFormatIsMisaligned is UnitTest + """'is' keyword at wrong column produces diagnostic.""" + fun name(): String => "TypeParameterFormat: 'is' misaligned flagged" + fun exclusion_group(): String => "ast-compile" + + fun apply(h: TestHelper) => + let source: String val = + "interface Hashable\n" + + "class Foo[\n" + + " A,\n" + + " B]\n" + + " is Hashable\n" + try + (let program, let sf) = _ASTTestHelper.compile(h, source)? + match program.package() + | let pkg: ast.Package val => + match pkg.module() + | let mod: ast.Module val => + let diags = + _CollectRuleDiags(mod, sf, lint.TypeParameterFormat) + h.assert_eq[USize](1, diags.size(), + "expected 1 diagnostic for 'is' misalignment") + try + h.assert_true( + diags(0)?.message.contains( + "'is' should align at column"), + "message should mention 'is' alignment") + else + h.fail("could not access diagnostic") + end + else + h.fail("no module") + end + else + h.fail("no package") + end + else + h.fail("compilation failed") + end + +class \nodoc\ _TestTypeParamFormatMultipleViolations is UnitTest + """Sharing line and misaligned 'is' produce two diagnostics.""" + fun name(): String => "TypeParamFormat: multiple violations" + fun exclusion_group(): String => "ast-compile" + + fun apply(h: TestHelper) => + let source: String val = + "interface Hashable\n" + + "class Foo[\n" + + " A, B,\n" + + " C]\n" + + " is Hashable\n" + try + (let program, let sf) = _ASTTestHelper.compile(h, source)? + match program.package() + | let pkg: ast.Package val => + match pkg.module() + | let mod: ast.Module val => + let diags = + _CollectRuleDiags(mod, sf, lint.TypeParameterFormat) + h.assert_eq[USize](2, diags.size(), + "expected 2 diagnostics for sharing + misaligned 'is'") + else + h.fail("no module") + end + else + h.fail("no package") + end + else + h.fail("compilation failed") + end diff --git a/tools/pony-lint/test/main.pony b/tools/pony-lint/test/main.pony index 86158649c0..85cd98a089 100644 --- a/tools/pony-lint/test/main.pony +++ b/tools/pony-lint/test/main.pony @@ -385,3 +385,24 @@ actor \nodoc\ Main is TestList test(_TestArrayLiteralFormatEmpty) test(_TestArrayLiteralFormatHangingIndent) test(_TestArrayLiteralFormatNoSpaceAfterOpen) + + // MethodDeclarationFormat tests + test(_TestMethodDeclFormatSingleLine) + test(_TestMethodDeclFormatMultiLineFunClean) + test(_TestMethodDeclFormatMultiLineNoReturnClean) + test(_TestMethodDeclFormatNewClean) + test(_TestMethodDeclFormatBeClean) + test(_TestMethodDeclFormatParamsSharingLine) + test(_TestMethodDeclFormatColonMisaligned) + test(_TestMethodDeclFormatArrowMisaligned) + test(_TestMethodDeclFormatMultipleViolations) + + // TypeParameterFormat tests + test(_TestTypeParamFormatSingleLine) + test(_TestTypeParamFormatMultiLineClassClean) + test(_TestTypeParamFormatTraitIsClean) + test(_TestTypeParamFormatMethodClean) + test(_TestTypeParamFormatBracketWrongLine) + test(_TestTypeParamFormatSharingLine) + test(_TestTypeParamFormatIsMisaligned) + test(_TestTypeParamFormatMultipleViolations) diff --git a/tools/pony-lint/type_parameter_format.pony b/tools/pony-lint/type_parameter_format.pony new file mode 100644 index 0000000000..efc64bac05 --- /dev/null +++ b/tools/pony-lint/type_parameter_format.pony @@ -0,0 +1,246 @@ +use ast = "pony_compiler" + +primitive TypeParameterFormat is ASTRule + """ + Checks formatting of multiline type parameter declarations per the style + guide. + + When type parameters span multiple lines, checks: + - The opening '[' is on the same line as the entity or method name. + - Each type parameter is on its own line (no two sharing a line). + - For entities with a provides clause ('is'), the 'is' keyword is at the + entity keyword's column + 2 when it appears as the first non-whitespace + on its line. + + Single-line type parameter lists are not checked (except for the '[' + same-line requirement, which always applies). + """ + fun id(): String val => "style/type-parameter-format" + fun category(): String val => "style" + + fun description(): String val => + "multiline type parameter formatting" + + " (bracket placement, layout, and 'is' alignment)" + + fun default_status(): RuleStatus => RuleOn + + fun node_filter(): Array[ast.TokenId] val => + [ ast.TokenIds.tk_class() + ast.TokenIds.tk_actor() + ast.TokenIds.tk_primitive() + ast.TokenIds.tk_struct() + ast.TokenIds.tk_trait() + ast.TokenIds.tk_interface() + ast.TokenIds.tk_type() + ast.TokenIds.tk_fun() + ast.TokenIds.tk_new() + ast.TokenIds.tk_be() ] + + fun check(node: ast.AST box, source: SourceFile val) + : Array[Diagnostic val] val + => + """ + Check formatting of type parameter declarations. + """ + let token_id = node.id() + let is_method = _is_method(token_id) + + // Child indices: entities have name at 0, typeparams at 1; + // methods have name at 1, typeparams at 2. + let name_idx: USize = if is_method then 1 else 0 end + let tp_idx: USize = if is_method then 2 else 1 end + + let name_node = + try node(name_idx)? + else return recover val Array[Diagnostic val] end + end + let typeparams = + try node(tp_idx)? + else return recover val Array[Diagnostic val] end + end + + // No type parameters — nothing to check + if typeparams.id() == ast.TokenIds.tk_none() then + return recover val Array[Diagnostic val] end + end + + let diags = recover iso Array[Diagnostic val] end + + // Check: '[' must be on the same line as the name. + // For TK_TYPEPARAMS, the position may reflect the '['. If it doesn't, + // fall back to the first child's line for the multiline check. + let tp_line = _typeparams_line(typeparams) + let name_line = name_node.line() + if (tp_line > 0) and (tp_line != name_line) then + diags.push(Diagnostic(id(), + "'[' should be on the same line as the name", + source.rel_path, tp_line, typeparams.pos())) + // Further checks are meaningless if '[' is misplaced + return consume diags + end + + // Determine if multiline + let num_tps = typeparams.num_children() + if num_tps > 1 then + try + let first_tp = typeparams(0)? + let first_line = _typeparam_line(first_tp) + let last_line = _max_typeparam_line(typeparams) + if first_line != last_line then + // Multiline: check each is on its own line + var prev_line = first_line + var i: USize = 1 + while i < num_tps do + let tp_node = typeparams(i)? + let tp_l = _typeparam_line(tp_node) + if tp_l == prev_line then + diags.push(Diagnostic(id(), + "each type parameter should be on its own line" + + " in a multiline declaration", + source.rel_path, tp_l, tp_node.pos())) + end + prev_line = tp_l + i = i + 1 + end + end + end + end + + // Check 'is' constraint alignment (entities/type aliases only) + if not is_method then + // Only check when type params are multiline + let multiline = + try + let first_tp = typeparams(0)? + let first_line = _typeparam_line(first_tp) + let last_line = _max_typeparam_line(typeparams) + first_line != last_line + else + false + end + if multiline then + try + let provides = node(3)? + if provides.id() != ast.TokenIds.tk_none() then + let prov_line = provides.line() + let keyword_col = node.pos() + try + let line_text = source.lines(prov_line - 1)? + (let word, let word_col) = + _first_nonws_word(line_text) + // Verify it's actually 'is' (not 'interface', 'if', etc.) + if word == "is" then + let expected_col = keyword_col + 2 + if word_col != expected_col then + diags.push(Diagnostic(id(), + "'is' should align at column " + + expected_col.string() + + " (indented from '" + + _keyword_name(token_id) + "')", + source.rel_path, prov_line, word_col)) + end + end + end + end + end + end + end + + consume diags + + fun _is_method(token_id: ast.TokenId): Bool => + """ + Check if the token represents a method declaration. + """ + (token_id == ast.TokenIds.tk_fun()) + or (token_id == ast.TokenIds.tk_new()) + or (token_id == ast.TokenIds.tk_be()) + + fun _keyword_name(token_id: ast.TokenId): String val => + """ + Return the source keyword for the given token type. + """ + if token_id == ast.TokenIds.tk_class() then "class" + elseif token_id == ast.TokenIds.tk_actor() then "actor" + elseif token_id == ast.TokenIds.tk_primitive() then "primitive" + elseif token_id == ast.TokenIds.tk_struct() then "struct" + elseif token_id == ast.TokenIds.tk_trait() then "trait" + elseif token_id == ast.TokenIds.tk_interface() then "interface" + elseif token_id == ast.TokenIds.tk_type() then "type" + elseif token_id == ast.TokenIds.tk_fun() then "fun" + elseif token_id == ast.TokenIds.tk_new() then "new" + elseif token_id == ast.TokenIds.tk_be() then "be" + else "unknown" + end + + fun _typeparams_line(typeparams: ast.AST box): USize => + """ + Get the line of the type parameters node. Since TK_TYPEPARAMS is + abstract, its position may not reflect the '['. Fall back to the + first child's line if the node's own line is 0. + """ + let l = typeparams.line() + if l > 0 then return l end + try _typeparam_line(typeparams(0)?) + else 0 + end + + fun _typeparam_line(tp: ast.AST box): USize => + """ + Get the line number of a type parameter node. Since TK_TYPEPARAM is + abstract, use the first child's line (the type parameter name). + """ + try tp(0)?.line() + else tp.line() + end + + fun _max_typeparam_line(typeparams: ast.AST box): USize => + """ + Find the maximum line number across all type parameter children. + """ + var max_line: USize = 0 + var i: USize = 0 + let count = typeparams.num_children() + while i < count do + try + let tp = typeparams(i)? + let l = _typeparam_line(tp) + if l > max_line then max_line = l end + end + i = i + 1 + end + max_line + + fun _first_nonws_word(line: String val): (String val, USize) => + """ + Extract the first non-whitespace word from a line, returning the word + and its 1-based column position. Returns `("", 0)` for blank lines. + """ + var i: USize = 0 + while i < line.size() do + try + let ch = line(i)? + if (ch != ' ') and (ch != '\t') then break end + end + i = i + 1 + end + + if i >= line.size() then + return ("", 0) + end + + let col = i + 1 // 1-based + let start = i + + while i < line.size() do + try + let ch = line(i)? + if (ch == ' ') or (ch == '\t') or (ch == '(') or (ch == '[') then + break + end + end + i = i + 1 + end + + (recover val line.substring(ISize.from[USize](start), + ISize.from[USize](i)) end, col)