diff --git a/.emmyrc.json b/.emmyrc.json
new file mode 100644
index 00000000..2d308c82
--- /dev/null
+++ b/.emmyrc.json
@@ -0,0 +1,48 @@
+{
+ "$schema": "https://raw.githubusercontent.com/EmmyLuaLs/emmylua-analyzer-rust/refs/heads/main/crates/emmylua_code_analysis/resources/schema.json",
+ "runtime": {
+ "version": "Lua5.2",
+ "requirePattern": [ "?", "?.lua" ]
+ },
+ "workspace": {
+ "$comment-moduleMap": "Rewrites each file's own module path rather than the require string",
+ "$comment-library": "The factorio typedefs are provided by a second config, generated with fmtk luals-addon in ci",
+ "$comment-templates": "The create templates contain __plugin_name__ placeholders and are not valid lua",
+ "ignoreGlobs": [
+ "**/node_modules/**",
+ "**/dist/**",
+ "database/**",
+ "docs/**",
+ "external_plugins/**",
+ "factorio/**",
+ "instances/**",
+ "logs/**",
+ "mods/**",
+ "modules/**",
+ "temp/**",
+ "packages/create/templates/**"
+ ],
+ "moduleMap": [
+ { "pattern": "^packages\\.host\\.modules\\.(.+)$", "replace": "modules.$1" },
+ { "pattern": "^plugins\\.([^.]+)\\.module\\.(.+)$", "replace": "modules.$1.$2" },
+ { "pattern": "^packages\\.host\\.lua\\.export\\.(.+)$", "replace": "$1" },
+ { "pattern": "^packages\\.host\\.lua\\.clusterio_lib\\.(.+)$", "replace": "$1" }
+ ]
+ },
+ "doc": {
+ "privateName": [ "__*" ]
+ },
+ "$comment-disable": "Same set as ExpCluster, these are editor level hints rather than ci checks",
+ "$comment-unnecessary-if": "Disabled by the config fmtk generates for itself, the api union types make it unreliable",
+ "diagnostics": {
+ "globals": [ "__DebugAdapter", "__Profiler" ],
+ "disable": [
+ "assign-type-mismatch",
+ "param-type-mismatch",
+ "preferred-local-alias",
+ "unnecessary-assert",
+ "unnecessary-if",
+ "unused"
+ ]
+ }
+}
diff --git a/.github/workflows/lua-lint.yml b/.github/workflows/lua-lint.yml
new file mode 100644
index 00000000..35b51ad2
--- /dev/null
+++ b/.github/workflows/lua-lint.yml
@@ -0,0 +1,83 @@
+name: Lua Lint
+
+on:
+ push:
+ pull_request:
+
+env:
+ EMMYLUA_CHECK_VERSION: "0.24.0"
+
+jobs:
+ lint:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: lts/*
+ - name: Generate Factorio typedefs
+ run: |
+ npm install --prefix "$RUNNER_TEMP/fmtk" factoriomod-debug
+ "$RUNNER_TEMP/fmtk/node_modules/.bin/fmtk" luals-addon "$RUNNER_TEMP"
+ - name: Install emmylua_check
+ run: |
+ curl -fsSL "https://github.com/EmmyLuaLs/emmylua-analyzer-rust/releases/download/${EMMYLUA_CHECK_VERSION}/emmylua_check-linux-x64.tar.gz" \
+ | tar -xz -C "$RUNNER_TEMP"
+ - name: Run Lint Report
+ shell: bash
+ run: |
+ # The typedefs are generated per run, so their path is passed via a second config
+ jq -n --arg lib "$RUNNER_TEMP/factorio/library" \
+ '{ workspace: { library: [ $lib ] } }' \
+ > "$RUNNER_TEMP/ci.emmyrc.json"
+
+ # Exits non zero on error severity, so the gate below is used instead
+ "$RUNNER_TEMP/emmylua_check" . --config ".emmyrc.json,$RUNNER_TEMP/ci.emmyrc.json" \
+ --output-format json --output check.json || true
+
+ # Credit to https://github.com/Krealle/luals-check-action/blob/main/action.yml
+ # Although some minor fixes were needed
+
+ # Format and print the messages (would be nice to format as a table, but that's too complex for jq)
+ jq -r --arg root "$PWD/" \
+ '.[] | (.file | ltrimstr($root)) as $file | .diagnostics[] |
+ {
+ file: $file,
+ code: .code,
+ line: .range.start.line,
+ message: (.message | split("\n")
+ | map(if startswith("- ") then .[2:] else . end)
+ | join("\n")),
+ severity: (if .severity <= 1 then "ERR" else "WARN" end)
+ } |
+ "**[\(.severity)] \(.code):** \(.file)#L\(.line + 1)
\(.message)"' \
+ check.json > "$GITHUB_STEP_SUMMARY"
+
+ echo ""
+
+ # Github Annotations (limited to 10)
+ jq -r --arg root "$PWD/" \
+ '.[] | (.file | ltrimstr($root)) as $file | .diagnostics[] |
+ {
+ file: $file,
+ title: .code,
+ line: .range.start.line,
+ endLine: .range.end.line,
+ col: .range.start.character,
+ endColumn: .range.end.character,
+ message: (.message | gsub("\n"; "%0A"; "m")),
+ level: (if .severity <= 1 then "error" else "warning" end)
+ } |
+ "::\(.level) file=\(.file),line=\(.line + 1),endLine=\(.endLine + 1),col=\(.col + 1),endColumn=\(.endColumn + 1),title=\(.title)::\(.message) (\(.title))"' \
+ check.json
+
+ # An entry is emitted for every file checked, including clean ones, so
+ # the gate counts diagnostics and asserts the workspace was actually read
+ if [[ $(jq 'length' check.json) -lt 20 ]] ; then
+ echo "::error::Only $(jq 'length' check.json) files were checked, expected the whole workspace"
+ exit 1
+ elif [[ $(jq '[.[].diagnostics[]] | length' check.json) -gt 0 ]] ; then
+ exit 1
+ else
+ echo "✅ All checks succeeded!" | tee "$GITHUB_STEP_SUMMARY"
+ fi