From cfafb69dddf4265d6e7aa5232514b85b3f829076 Mon Sep 17 00:00:00 2001 From: Daniel Widgren Date: Sat, 14 Mar 2026 16:43:14 +0000 Subject: [PATCH] feat: add Erlang problem matchers (#433) * feat: add Erlang problem matchers for erlc, dialyzer, CT, and EUnit Adds GitHub Actions problem matchers for Erlang tooling, matching the existing Elixir matcher support. Covers erlc errors/warnings (which also catches dialyzer output), Common Test failures, and EUnit failures. Closes #390 * chore: rebuild dist bundle and format test file * refactor: move Erlang matchers into installOTP function Moves maybeEnableErlangProblemMatchers() call inside installOTP(), between setOutput and endGroup, matching the Elixir pattern. * refactor: extract generic maybeEnableProblemMatchers function Replaces separate maybeEnableErlangProblemMatchers and maybeEnableElixirProblemMatchers with a single generic maybeEnableProblemMatchers(language) and a problemMatchersEnabled() helper. * fix: use static paths for ncc to resolve matcher JSON files ncc cannot trace dynamic template literals, so the matcher JSON files were not being copied to dist/. Use a static map of paths instead. --- dist/erlang-matchers.json | 66 ++++++++++++++++++++++++ dist/index.js | 23 +++++---- matchers/erlang-matchers.json | 66 ++++++++++++++++++++++++ src/setup-beam.js | 23 +++++---- test/setup-beam.test.js | 94 +++++++++++++++++++++++++++++++++++ 5 files changed, 254 insertions(+), 18 deletions(-) create mode 100644 dist/erlang-matchers.json create mode 100644 matchers/erlang-matchers.json diff --git a/dist/erlang-matchers.json b/dist/erlang-matchers.json new file mode 100644 index 0000000..4707061 --- /dev/null +++ b/dist/erlang-matchers.json @@ -0,0 +1,66 @@ +{ + "problemMatcher": [ + { + "owner": "erlc-warning", + "severity": "warning", + "pattern": [ + { + "regexp": "^(\\S+):(\\d+):(\\d+): [wW]arning: (.+)$", + "file": 1, + "line": 2, + "column": 3, + "message": 4 + } + ] + }, + { + "owner": "erlc-error", + "severity": "error", + "pattern": [ + { + "regexp": "^(\\S+):(\\d+):(\\d+): (?![wW]arning)(.+)$", + "file": 1, + "line": 2, + "column": 3, + "message": 4 + } + ] + }, + { + "owner": "ct-failure", + "severity": "error", + "pattern": [ + { + "regexp": "^(\\S+) failed on line (\\d+)$", + "file": 1, + "line": 2 + }, + { + "regexp": "^Reason: (.+)$", + "message": 1 + } + ] + }, + { + "owner": "eunit-failure", + "severity": "error", + "pattern": [ + { + "regexp": "^\\S+: .+\\.\\.\\.\\*failed\\*$" + }, + { + "regexp": "^in function .+ \\((.+), line (\\d+)\\)$", + "file": 1, + "line": 2 + }, + { + "regexp": "^in call from .+$" + }, + { + "regexp": "^\\*\\*error:(.+)$", + "message": 1 + } + ] + } + ] +} diff --git a/dist/index.js b/dist/index.js index 3c17446..5d866a3 100644 --- a/dist/index.js +++ b/dist/index.js @@ -57588,6 +57588,7 @@ async function installOTP(otpSpec) { }, }) setOutput('otp-version', otpVersion) + maybeEnableProblemMatchers('erlang') endGroup() return otpVersion @@ -57609,7 +57610,7 @@ async function maybeInstallElixir(elixirSpec) { }, }) setOutput('elixir-version', elixirVersion) - maybeEnableElixirProblemMatchers() + maybeEnableProblemMatchers('elixir') endGroup() installed = true @@ -57618,14 +57619,18 @@ async function maybeInstallElixir(elixirSpec) { return installed } -function maybeEnableElixirProblemMatchers() { - const disableProblemMatchers = setup_beam_getInput('disable_problem_matchers', false) - if (disableProblemMatchers === 'false') { - // path.join helps ncc figure this out - const elixirMatchers = external_node_path_namespaceObject.join( - __nccwpck_require__.ab + "elixir-matchers.json", - ) - info(`##[add-matcher]${elixirMatchers}`) +function problemMatchersEnabled() { + return setup_beam_getInput('disable_problem_matchers', false) === 'false' +} + +// path.join with static strings helps ncc resolve and bundle the JSON files +function maybeEnableProblemMatchers(language) { + if (problemMatchersEnabled()) { + const matcherFiles = { + erlang: external_node_path_namespaceObject.join(__nccwpck_require__.ab + "erlang-matchers.json"), + elixir: external_node_path_namespaceObject.join(__nccwpck_require__.ab + "elixir-matchers.json"), + } + info(`##[add-matcher]${matcherFiles[language]}`) } } diff --git a/matchers/erlang-matchers.json b/matchers/erlang-matchers.json new file mode 100644 index 0000000..4707061 --- /dev/null +++ b/matchers/erlang-matchers.json @@ -0,0 +1,66 @@ +{ + "problemMatcher": [ + { + "owner": "erlc-warning", + "severity": "warning", + "pattern": [ + { + "regexp": "^(\\S+):(\\d+):(\\d+): [wW]arning: (.+)$", + "file": 1, + "line": 2, + "column": 3, + "message": 4 + } + ] + }, + { + "owner": "erlc-error", + "severity": "error", + "pattern": [ + { + "regexp": "^(\\S+):(\\d+):(\\d+): (?![wW]arning)(.+)$", + "file": 1, + "line": 2, + "column": 3, + "message": 4 + } + ] + }, + { + "owner": "ct-failure", + "severity": "error", + "pattern": [ + { + "regexp": "^(\\S+) failed on line (\\d+)$", + "file": 1, + "line": 2 + }, + { + "regexp": "^Reason: (.+)$", + "message": 1 + } + ] + }, + { + "owner": "eunit-failure", + "severity": "error", + "pattern": [ + { + "regexp": "^\\S+: .+\\.\\.\\.\\*failed\\*$" + }, + { + "regexp": "^in function .+ \\((.+), line (\\d+)\\)$", + "file": 1, + "line": 2 + }, + { + "regexp": "^in call from .+$" + }, + { + "regexp": "^\\*\\*error:(.+)$", + "message": 1 + } + ] + } + ] +} diff --git a/src/setup-beam.js b/src/setup-beam.js index 1161b31..29a802d 100644 --- a/src/setup-beam.js +++ b/src/setup-beam.js @@ -80,6 +80,7 @@ async function installOTP(otpSpec) { }, }) core.setOutput('otp-version', otpVersion) + maybeEnableProblemMatchers('erlang') core.endGroup() return otpVersion @@ -101,7 +102,7 @@ async function maybeInstallElixir(elixirSpec) { }, }) core.setOutput('elixir-version', elixirVersion) - maybeEnableElixirProblemMatchers() + maybeEnableProblemMatchers('elixir') core.endGroup() installed = true @@ -110,14 +111,18 @@ async function maybeInstallElixir(elixirSpec) { return installed } -function maybeEnableElixirProblemMatchers() { - const disableProblemMatchers = getInput('disable_problem_matchers', false) - if (disableProblemMatchers === 'false') { - // path.join helps ncc figure this out - const elixirMatchers = path.join( - `${__dirname}/../matchers/elixir-matchers.json`, - ) - core.info(`##[add-matcher]${elixirMatchers}`) +function problemMatchersEnabled() { + return getInput('disable_problem_matchers', false) === 'false' +} + +// path.join with static strings helps ncc resolve and bundle the JSON files +function maybeEnableProblemMatchers(language) { + if (problemMatchersEnabled()) { + const matcherFiles = { + erlang: path.join(`${__dirname}/../matchers/erlang-matchers.json`), + elixir: path.join(`${__dirname}/../matchers/elixir-matchers.json`), + } + core.info(`##[add-matcher]${matcherFiles[language]}`) } } diff --git a/test/setup-beam.test.js b/test/setup-beam.test.js index b8354ef..fc5fad1 100644 --- a/test/setup-beam.test.js +++ b/test/setup-beam.test.js @@ -6,7 +6,9 @@ import path from 'node:path' import { describe, it } from 'node:test' import * as csv from 'csv-parse/sync' import elixirMatchers from '../matchers/elixir-matchers.json' with { type: 'json' } +import erlangMatchers from '../matchers/erlang-matchers.json' with { type: 'json' } const { problemMatcher } = elixirMatchers +const { problemMatcher: erlangProblemMatcher } = erlangMatchers process.env.NODE_ENV = 'test' @@ -1159,6 +1161,98 @@ describe("Elixir Mix matcher's", () => { }) }) +describe("Erlang matcher's", () => { + it('erlc errors are properly matched', () => { + const [matcher] = erlangProblemMatcher.find( + ({ owner }) => owner === 'erlc-error', + ).pattern + + const output = 'src/mymod.erl:42:5: head mismatch' + const match = output.match(matcher.regexp) + assert.equal(match[1], 'src/mymod.erl') + assert.equal(match[2], '42') + assert.equal(match[3], '5') + assert.equal(match[4], 'head mismatch') + }) + + it('erlc errors do not match warnings', () => { + const [matcher] = erlangProblemMatcher.find( + ({ owner }) => owner === 'erlc-error', + ).pattern + + const output = 'src/mymod.erl:42:5: Warning: variable X is unused' + const match = output.match(matcher.regexp) + assert.equal(match, null) + }) + + it('erlc warnings are properly matched', () => { + const [matcher] = erlangProblemMatcher.find( + ({ owner }) => owner === 'erlc-warning', + ).pattern + + const output = 'src/mymod.erl:10:3: Warning: variable X is unused' + const match = output.match(matcher.regexp) + assert.equal(match[1], 'src/mymod.erl') + assert.equal(match[2], '10') + assert.equal(match[3], '3') + assert.equal(match[4], 'variable X is unused') + }) + + it('dialyzer warnings are matched via erlc patterns', () => { + const [matcher] = erlangProblemMatcher.find( + ({ owner }) => owner === 'erlc-warning', + ).pattern + + const output = + 'src/mymod.erl:25:1: warning: Function foo/0 has no local return' + const match = output.match(matcher.regexp) + assert.equal(match[1], 'src/mymod.erl') + assert.equal(match[2], '25') + assert.equal(match[3], '1') + assert.equal(match[4], 'Function foo/0 has no local return') + }) + + it('CT failures are properly matched', () => { + const [filePattern, reasonPattern] = erlangProblemMatcher.find( + ({ owner }) => owner === 'ct-failure', + ).pattern + + const firstOutput = 'test/mymod_SUITE.erl failed on line 55' + const secondOutput = 'Reason: {badmatch,{error,timeout}}' + + const fileMatch = firstOutput.match(filePattern.regexp) + assert.equal(fileMatch[1], 'test/mymod_SUITE.erl') + assert.equal(fileMatch[2], '55') + + const reasonMatch = secondOutput.match(reasonPattern.regexp) + assert.equal(reasonMatch[1], '{badmatch,{error,timeout}}') + }) + + it('EUnit failures are properly matched', () => { + const [headerPattern, funcPattern, callPattern, errorPattern] = + erlangProblemMatcher.find( + ({ owner }) => owner === 'eunit-failure', + ).pattern + + const header = 'mymod_test: hello_test....*failed*' + const func = + 'in function mymod_test:hello_test/0 (test/mymod_test.erl, line 12)' + const call = 'in call from mymod_test:hello_test/0' + const error = '**error:{badmatch,false}' + + assert.ok(header.match(headerPattern.regexp)) + + const funcMatch = func.match(funcPattern.regexp) + assert.equal(funcMatch[1], 'test/mymod_test.erl') + assert.equal(funcMatch[2], '12') + + assert.ok(call.match(callPattern.regexp)) + + const errorMatch = error.match(errorPattern.regexp) + assert.equal(errorMatch[1], '{badmatch,false}') + }) +}) + function unsimulateInput(key) { return simulateInput(key, '') }