#!/usr/bin/env bash set -euo pipefail cd "$(dirname "$0")" linuxcnc_root=${LINUXCNC_ROOT:-../linuxcnc} manifest=${1:-linuxcnc-kinematics-source-files.txt} usage() { echo "usage: $0 [manifest]" >&2 } if [[ "${manifest:-}" == "--help" || "${manifest:-}" == "-h" ]]; then usage exit 0 fi if [[ "${manifest:-}" == --* ]]; then usage echo "unknown switchkins remap table checker option: $manifest" >&2 exit 1 fi if [[ -n "${2:-}" ]]; then usage echo "too many switchkins remap table checker arguments" >&2 exit 1 fi if [[ ! -d "$linuxcnc_root" ]]; then echo "missing LinuxCNC root: $linuxcnc_root" >&2 exit 1 fi if [[ ! -f "$manifest" ]]; then echo "missing kinematics manifest: $manifest" >&2 exit 1 fi validate_manifest_remap_kinstypes() { local manifest_file=$1 if command -v python3 >/dev/null 2>&1; then if ! python3 - "$linuxcnc_root" "$manifest_file" <<'PY' import os import re import sys linuxcnc_root, manifest_file = sys.argv[1:] remap_re = re.compile(r'^\s*#\s*=\s*(-?\d+)\b') seen = {} with open(manifest_file, encoding="utf-8") as manifest: for line_number, line in enumerate(manifest, 1): parts = line.rstrip("\n").split(":", 2) if len(parts) < 2 or parts[0] != "remap": continue path = parts[1] basename = os.path.basename(path) if basename not in {"428remap.ngc", "429remap.ngc", "430remap.ngc"}: raise SystemExit(f"unexpected switchkins remap source at {manifest_file}:{line_number}: {path}") if path in seen: raise SystemExit(f"duplicate switchkins remap source at {manifest_file}:{line_number}: {path}") seen[path] = line_number source = os.path.join(linuxcnc_root, path) with open(source, encoding="utf-8") as handle: kinstypes = [int(match.group(1)) for match in map(remap_re.match, handle) if match] if len(kinstypes) != 1: raise SystemExit(f"expected exactly one # assignment in {path}, found {len(kinstypes)}") if kinstypes[0] < 0: raise SystemExit(f"invalid negative # assignment in {path}: {kinstypes[0]}") if not seen: raise SystemExit(f"no switchkins remap sources listed in {manifest_file}") PY then exit 1 fi elif command -v node >/dev/null 2>&1; then if ! node - "$linuxcnc_root" "$manifest_file" <<'JS' const fs = require("fs"); const path = require("path"); const [linuxcncRoot, manifestFile] = process.argv.slice(2); const remapRe = /^\s*#\s*=\s*(-?\d+)\b/; const seen = new Map(); for (const [index, line] of fs.readFileSync(manifestFile, "utf8").split(/\r?\n/).entries()) { const parts = line.split(":", 3); if (parts.length < 2 || parts[0] !== "remap") { continue; } const sourcePath = parts[1]; const basename = path.basename(sourcePath); if (!["428remap.ngc", "429remap.ngc", "430remap.ngc"].includes(basename)) { throw new Error(`unexpected switchkins remap source at ${manifestFile}:${index + 1}: ${sourcePath}`); } if (seen.has(sourcePath)) { throw new Error(`duplicate switchkins remap source at ${manifestFile}:${index + 1}: ${sourcePath}`); } seen.set(sourcePath, index + 1); const kinstypes = fs.readFileSync(path.join(linuxcncRoot, sourcePath), "utf8") .split(/\r?\n/) .map((sourceLine) => remapRe.exec(sourceLine)) .filter(Boolean) .map((match) => Number(match[1])); if (kinstypes.length !== 1) { throw new Error(`expected exactly one # assignment in ${sourcePath}, found ${kinstypes.length}`); } if (kinstypes[0] < 0) { throw new Error(`invalid negative # assignment in ${sourcePath}: ${kinstypes[0]}`); } } if (seen.size === 0) { throw new Error(`no switchkins remap sources listed in ${manifestFile}`); } JS then exit 1 fi else echo "missing python3 or node for switchkins remap manifest validation" >&2 exit 1 fi } validate_manifest_remap_kinstypes "$manifest" validate_manifest_config_remaps() { local manifest_file=$1 if command -v python3 >/dev/null 2>&1; then if ! python3 - "$linuxcnc_root" "$manifest_file" <<'PY' import os import re import sys linuxcnc_root, manifest_file = sys.argv[1:] remap_re = re.compile(r'REMAP\s*=\s*M(428|429|430)(?:\D|$)', re.I) seen = {} with open(manifest_file, encoding="utf-8") as manifest: for line_number, line in enumerate(manifest, 1): parts = line.rstrip("\n").split(":", 2) if len(parts) < 2 or parts[0] != "config" or not parts[1].endswith(".ini"): continue source_path = parts[1] source = os.path.join(linuxcnc_root, source_path) with open(source, encoding="utf-8") as handle: mcodes = {int(match.group(1)) for match in remap_re.finditer(handle.read())} if not mcodes: continue seen[source_path] = mcodes missing = {428, 429} - mcodes if missing: raise SystemExit(f"switchkins config {source_path} is missing required M-code remap(s): {sorted(missing)}") if not seen: raise SystemExit(f"no switchkins REMAP config sources listed in {manifest_file}") PY then exit 1 fi elif command -v node >/dev/null 2>&1; then if ! node - "$linuxcnc_root" "$manifest_file" <<'JS' const fs = require("fs"); const path = require("path"); const [linuxcncRoot, manifestFile] = process.argv.slice(2); const remapRe = /REMAP\s*=\s*M(428|429|430)(?:\D|$)/ig; const seen = new Map(); for (const line of fs.readFileSync(manifestFile, "utf8").split(/\r?\n/)) { const parts = line.split(":", 3); if (parts.length < 2 || parts[0] !== "config" || !parts[1].endsWith(".ini")) { continue; } const sourcePath = parts[1]; const text = fs.readFileSync(path.join(linuxcncRoot, sourcePath), "utf8"); const mcodes = new Set(); for (const match of text.matchAll(remapRe)) { mcodes.add(Number(match[1])); } if (mcodes.size === 0) { continue; } seen.set(sourcePath, mcodes); const missing = [428, 429].filter((mcode) => !mcodes.has(mcode)); if (missing.length > 0) { throw new Error(`switchkins config ${sourcePath} is missing required M-code remap(s): ${missing.join(",")}`); } } if (seen.size === 0) { throw new Error(`no switchkins REMAP config sources listed in ${manifestFile}`); } JS then exit 1 fi else echo "missing python3 or node for switchkins remap config validation" >&2 exit 1 fi } validate_manifest_config_remaps "$manifest" validate_generator_cli_contract() { local manifest_file=$1 local cli_dir cli_dir=$(mktemp -d "${TMPDIR:-/tmp}/linuxcnc_switchkins_remap_cli.XXXXXX") LINUXCNC_ROOT="$linuxcnc_root" ./generate-linuxcnc-switchkins-remap-table.sh --help >/dev/null 2>&1 if LINUXCNC_ROOT="$linuxcnc_root" ./generate-linuxcnc-switchkins-remap-table.sh --unknown "$manifest_file" >/dev/null 2>&1; then echo "switchkins remap generator accepted unknown option" >&2 exit 1 fi if LINUXCNC_ROOT="$linuxcnc_root" ./generate-linuxcnc-switchkins-remap-table.sh --all-output-dir >/dev/null 2>&1; then echo "switchkins remap generator accepted --all-output-dir without directory" >&2 exit 1 fi if LINUXCNC_ROOT="$linuxcnc_root" ./generate-linuxcnc-switchkins-remap-table.sh "$manifest_file" extra >/dev/null 2>&1; then echo "switchkins remap generator accepted too many arguments" >&2 exit 1 fi LINUXCNC_ROOT="$linuxcnc_root" ./generate-linuxcnc-switchkins-remap-table.sh --all-output-dir "$cli_dir/out" "$manifest_file" for generated_file in \ linuxcnc_switchkins_remap_table.inc \ linuxcnc_switchkins_remap_config_cases.inc \ linuxcnc_switchkins_remap_config_cases.json do if [[ ! -s "$cli_dir/out/$generated_file" ]]; then echo "switchkins remap generator --all-output-dir did not write $generated_file" >&2 exit 1 fi done rm -rf "$cli_dir" } validate_generator_cli_contract "$manifest" generated_dir=$(mktemp -d "${TMPDIR:-/tmp}/linuxcnc_switchkins_remap.XXXXXX") trap 'rm -rf "$generated_dir"' EXIT LINUXCNC_ROOT="$linuxcnc_root" ./generate-linuxcnc-switchkins-remap-table.sh --all-output-dir "$generated_dir" "$manifest" LINUXCNC_ROOT="$linuxcnc_root" ./generate-linuxcnc-switchkins-remap-table.sh "$manifest" \ >"$generated_dir/linuxcnc_switchkins_remap_table.stdout.inc" LINUXCNC_ROOT="$linuxcnc_root" ./generate-linuxcnc-switchkins-remap-table.sh --config-cases "$manifest" \ >"$generated_dir/linuxcnc_switchkins_remap_config_cases.stdout.inc" LINUXCNC_ROOT="$linuxcnc_root" ./generate-linuxcnc-switchkins-remap-table.sh --json-cases "$manifest" \ >"$generated_dir/linuxcnc_switchkins_remap_config_cases.stdout.json" validate_json() { local file=$1 local label=$2 if command -v python3 >/dev/null 2>&1; then if ! python3 - "$file" <<'PY' import json import sys with open(sys.argv[1], encoding="utf-8") as handle: data = json.load(handle) if not isinstance(data, list) or not data: raise SystemExit("expected non-empty JSON array") seen = set() seen_values = {} for index, entry in enumerate(data): if not isinstance(entry, dict): raise SystemExit(f"entry {index} is not an object") if set(entry) != {"field", "value", "m428", "m429", "m430"}: raise SystemExit(f"entry {index} has unexpected keys") if not isinstance(entry["field"], str) or not entry["field"]: raise SystemExit(f"entry {index} has invalid field") if not isinstance(entry["value"], str) or not entry["value"]: raise SystemExit(f"entry {index} has invalid value") for key in ("m428", "m429", "m430"): if not isinstance(entry[key], int): raise SystemExit(f"entry {index} has non-integer {key}") case_key = (entry["field"], entry["value"], entry["m428"], entry["m429"], entry["m430"]) if case_key in seen: raise SystemExit(f"entry {index} duplicates an earlier case") seen.add(case_key) value_key = (entry["field"], entry["value"]) types = (entry["m428"], entry["m429"], entry["m430"]) if value_key in seen_values and seen_values[value_key] != types: raise SystemExit(f"entry {index} has ambiguous field/value mapping") seen_values[value_key] = types PY then echo "invalid switchkins remap JSON: $label" >&2 exit 1 fi elif command -v node >/dev/null 2>&1; then if ! node - "$file" <<'JS' const fs = require("fs"); const data = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); if (!Array.isArray(data) || data.length === 0) { throw new Error("expected non-empty JSON array"); } const seen = new Set(); const seenValues = new Map(); for (const [index, entry] of data.entries()) { if (!entry || typeof entry !== "object" || Array.isArray(entry)) { throw new Error(`entry ${index} is not an object`); } const keys = Object.keys(entry).sort(); const expected = ["field", "m428", "m429", "m430", "value"]; if (keys.length !== expected.length || keys.some((key, i) => key !== expected[i])) { throw new Error(`entry ${index} has unexpected keys`); } if (typeof entry.field !== "string" || entry.field.length === 0) { throw new Error(`entry ${index} has invalid field`); } if (typeof entry.value !== "string" || entry.value.length === 0) { throw new Error(`entry ${index} has invalid value`); } for (const key of ["m428", "m429", "m430"]) { if (!Number.isInteger(entry[key])) { throw new Error(`entry ${index} has non-integer ${key}`); } } const caseKey = JSON.stringify([entry.field, entry.value, entry.m428, entry.m429, entry.m430]); if (seen.has(caseKey)) { throw new Error(`entry ${index} duplicates an earlier case`); } seen.add(caseKey); const valueKey = JSON.stringify([entry.field, entry.value]); const types = JSON.stringify([entry.m428, entry.m429, entry.m430]); if (seenValues.has(valueKey) && seenValues.get(valueKey) !== types) { throw new Error(`entry ${index} has ambiguous field/value mapping`); } seenValues.set(valueKey, types); } JS then echo "invalid switchkins remap JSON: $label" >&2 exit 1 fi else echo "missing python3 or node for switchkins remap JSON validation" >&2 exit 1 fi } count_cpp_config_cases() { local file=$1 grep -Ec '^[[:space:]]*\{"[^"]+", "[^"]+", -?[0-9]+, -?[0-9]+, -?[0-9]+\},?$' "$file" } count_table_rows() { local file=$1 grep -Ec '^[[:space:]]*\{"[^"]+", -?[0-9]+, -?[0-9]+, -?[0-9]+\},?$' "$file" } count_json_cases() { local file=$1 if command -v python3 >/dev/null 2>&1; then python3 - "$file" <<'PY' import json import sys with open(sys.argv[1], encoding="utf-8") as handle: print(len(json.load(handle))) PY elif command -v node >/dev/null 2>&1; then node -e 'console.log(JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).length)' "$file" else echo "missing python3 or node for switchkins remap JSON counting" >&2 exit 1 fi } validate_generated_header() { local file=$1 local label=$2 local expected=$3 if [[ "$(head -n 1 "$file")" != "$expected" ]]; then echo "unexpected generated switchkins remap $label header in $file" >&2 exit 1 fi } validate_cpp_table() { local file=$1 local label=$2 if command -v python3 >/dev/null 2>&1; then if ! python3 - "$file" "$label" <<'PY' import json import re import sys file, label = sys.argv[1:] uppercase_or_space_re = re.compile(r'[A-Z\s]') row_re = re.compile( r'^\s*\{("(?:(?:\\.)|[^"\\])*"),\s*' r'(-?\d+),\s*(-?\d+),\s*(-?\d+)\},?\s*$' ) aliases = {} row_count = 0 with open(file, encoding="utf-8") as handle: for line_number, line in enumerate(handle, 1): match = row_re.match(line) if not match: continue row_count += 1 alias = json.loads(match.group(1)) if not alias: raise SystemExit(f"{label} table has empty alias at line {line_number}") if uppercase_or_space_re.search(alias): raise SystemExit(f"{label} table has unnormalized alias {alias!r} at line {line_number}") if alias in aliases: raise SystemExit( f"{label} table has duplicate alias {alias!r} at lines " f"{aliases[alias]} and {line_number}" ) aliases[alias] = line_number types = (int(match.group(2)), int(match.group(3)), int(match.group(4))) if types[0] < 0 or types[1] < 0: raise SystemExit(f"{label} table has missing M428/M429 kinstype at line {line_number}") for type_value in types: if type_value < -1: raise SystemExit(f"{label} table has invalid kinstype {type_value} at line {line_number}") if row_count == 0: raise SystemExit(f"{label} table has no rows") PY then exit 1 fi elif command -v node >/dev/null 2>&1; then if ! node - "$file" "$label" <<'JS' const fs = require("fs"); const [file, label] = process.argv.slice(2); const rowRe = /^\s*\{("(?:(?:\\.)|[^"\\])*"),\s*(-?\d+),\s*(-?\d+),\s*(-?\d+)\},?\s*$/; const uppercaseOrSpaceRe = /[A-Z\s]/; const aliases = new Map(); let rowCount = 0; for (const [index, line] of fs.readFileSync(file, "utf8").split(/\r?\n/).entries()) { const lineNumber = index + 1; const match = rowRe.exec(line); if (!match) { continue; } rowCount += 1; const alias = JSON.parse(match[1]); if (!alias) { throw new Error(`${label} table has empty alias at line ${lineNumber}`); } if (uppercaseOrSpaceRe.test(alias)) { throw new Error(`${label} table has unnormalized alias ${JSON.stringify(alias)} at line ${lineNumber}`); } if (aliases.has(alias)) { throw new Error(`${label} table has duplicate alias ${JSON.stringify(alias)} at lines ${aliases.get(alias)} and ${lineNumber}`); } aliases.set(alias, lineNumber); const types = [Number(match[2]), Number(match[3]), Number(match[4])]; if (types[0] < 0 || types[1] < 0) { throw new Error(`${label} table has missing M428/M429 kinstype at line ${lineNumber}`); } for (const typeValue of types) { if (typeValue < -1) { throw new Error(`${label} table has invalid kinstype ${typeValue} at line ${lineNumber}`); } } } if (rowCount === 0) { throw new Error(`${label} table has no rows`); } JS then exit 1 fi else echo "missing python3 or node for switchkins remap table validation" >&2 exit 1 fi } validate_cpp_cases() { local file=$1 local label=$2 if command -v python3 >/dev/null 2>&1; then if ! python3 - "$file" "$label" <<'PY' import json import re import sys file, label = sys.argv[1:] row_re = re.compile( r'^\s*\{("(?:(?:\\.)|[^"\\])*"),\s*' r'("(?:(?:\\.)|[^"\\])*"),\s*' r'(-?\d+),\s*(-?\d+),\s*(-?\d+)\},?\s*$' ) seen = set() seen_values = {} row_count = 0 with open(file, encoding="utf-8") as handle: for line_number, line in enumerate(handle, 1): match = row_re.match(line) if not match: continue row_count += 1 field = json.loads(match.group(1)) value = json.loads(match.group(2)) types = (int(match.group(3)), int(match.group(4)), int(match.group(5))) if types[0] < 0 or types[1] < 0: raise SystemExit(f"{label} cases have missing M428/M429 kinstype at line {line_number}") if any(type_value < -1 for type_value in types): raise SystemExit(f"{label} cases have invalid kinstype at line {line_number}") if not field or not value: raise SystemExit(f"{label} cases have empty field/value at line {line_number}") case_key = (field, value, *types) if case_key in seen: raise SystemExit(f"{label} cases duplicate an earlier case at line {line_number}") seen.add(case_key) value_key = (field, value) if value_key in seen_values and seen_values[value_key] != types: raise SystemExit(f"{label} cases have ambiguous field/value mapping at line {line_number}") seen_values[value_key] = types if row_count == 0: raise SystemExit(f"{label} cases have no rows") PY then exit 1 fi elif command -v node >/dev/null 2>&1; then if ! node - "$file" "$label" <<'JS' const fs = require("fs"); const [file, label] = process.argv.slice(2); const rowRe = /^\s*\{("(?:(?:\\.)|[^"\\])*"),\s*("(?:(?:\\.)|[^"\\])*"),\s*(-?\d+),\s*(-?\d+),\s*(-?\d+)\},?\s*$/; const seen = new Set(); const seenValues = new Map(); let rowCount = 0; for (const [index, line] of fs.readFileSync(file, "utf8").split(/\r?\n/).entries()) { const lineNumber = index + 1; const match = rowRe.exec(line); if (!match) { continue; } rowCount += 1; const field = JSON.parse(match[1]); const value = JSON.parse(match[2]); const types = [Number(match[3]), Number(match[4]), Number(match[5])]; if (types[0] < 0 || types[1] < 0) { throw new Error(`${label} cases have missing M428/M429 kinstype at line ${lineNumber}`); } if (types.some((typeValue) => typeValue < -1)) { throw new Error(`${label} cases have invalid kinstype at line ${lineNumber}`); } if (!field || !value) { throw new Error(`${label} cases have empty field/value at line ${lineNumber}`); } const caseKey = JSON.stringify([field, value, ...types]); if (seen.has(caseKey)) { throw new Error(`${label} cases duplicate an earlier case at line ${lineNumber}`); } seen.add(caseKey); const valueKey = JSON.stringify([field, value]); const typeKey = JSON.stringify(types); if (seenValues.has(valueKey) && seenValues.get(valueKey) !== typeKey) { throw new Error(`${label} cases have ambiguous field/value mapping at line ${lineNumber}`); } seenValues.set(valueKey, typeKey); } if (rowCount === 0) { throw new Error(`${label} cases have no rows`); } JS then exit 1 fi else echo "missing python3 or node for switchkins remap C++ case validation" >&2 exit 1 fi } compare_cpp_json_cases() { local cpp_file=$1 local json_file=$2 local label=$3 if command -v python3 >/dev/null 2>&1; then if ! python3 - "$cpp_file" "$json_file" "$label" <<'PY' import json import re import sys cpp_file, json_file, label = sys.argv[1:] row_re = re.compile( r'^\s*\{("(?:(?:\\.)|[^"\\])*"),\s*' r'("(?:(?:\\.)|[^"\\])*"),\s*' r'(-?\d+),\s*(-?\d+),\s*(-?\d+)\},?\s*$' ) cpp_cases = [] with open(cpp_file, encoding="utf-8") as handle: for line_number, line in enumerate(handle, 1): match = row_re.match(line) if not match: continue cpp_cases.append({ "field": json.loads(match.group(1)), "value": json.loads(match.group(2)), "m428": int(match.group(3)), "m429": int(match.group(4)), "m430": int(match.group(5)), }) with open(json_file, encoding="utf-8") as handle: json_cases = json.load(handle) if cpp_cases != json_cases: raise SystemExit(f"{label} C++/JSON switchkins remap cases differ") PY then exit 1 fi elif command -v node >/dev/null 2>&1; then if ! node - "$cpp_file" "$json_file" "$label" <<'JS' const fs = require("fs"); const [cppFile, jsonFile, label] = process.argv.slice(2); const rowRe = /^\s*\{("(?:(?:\\.)|[^"\\])*"),\s*("(?:(?:\\.)|[^"\\])*"),\s*(-?\d+),\s*(-?\d+),\s*(-?\d+)\},?\s*$/; const cppCases = []; for (const line of fs.readFileSync(cppFile, "utf8").split(/\r?\n/)) { const match = rowRe.exec(line); if (!match) { continue; } cppCases.push({ field: JSON.parse(match[1]), value: JSON.parse(match[2]), m428: Number(match[3]), m429: Number(match[4]), m430: Number(match[5]), }); } const jsonCases = JSON.parse(fs.readFileSync(jsonFile, "utf8")); if (JSON.stringify(cppCases) !== JSON.stringify(jsonCases)) { throw new Error(`${label} C++/JSON switchkins remap cases differ`); } JS then exit 1 fi else echo "missing python3 or node for switchkins remap C++/JSON comparison" >&2 exit 1 fi } extract_api_switchkins_alias_keys() { local file=$1 if command -v python3 >/dev/null 2>&1; then python3 - "$file" <<'PY' import re import sys file = sys.argv[1] pattern = re.compile(r'contains_string_value\(compact,\s*"([^"]+)",\s*alias\)') function_started = False brace_depth = 0 keys = [] with open(file, encoding="utf-8") as handle: for line in handle: if not function_started: if line.startswith("bool contains_switchkins_config_alias("): function_started = True brace_depth += line.count("{") - line.count("}") continue brace_depth += line.count("{") - line.count("}") keys.extend(pattern.findall(line)) if brace_depth <= 0: break if not keys: raise SystemExit("missing switchkins config alias keys in cnc_sim_api.cpp") for key in keys: print(key) PY elif command -v node >/dev/null 2>&1; then node - "$file" <<'JS' const fs = require("fs"); const file = process.argv[2]; const pattern = /contains_string_value\(compact,\s*"([^"]+)",\s*alias\)/g; const lines = fs.readFileSync(file, "utf8").split(/\r?\n/); let started = false; let braceDepth = 0; const keys = []; for (const line of lines) { if (!started) { if (line.startsWith("bool contains_switchkins_config_alias(")) { started = true; braceDepth += (line.match(/{/g) || []).length - (line.match(/}/g) || []).length; } continue; } braceDepth += (line.match(/{/g) || []).length - (line.match(/}/g) || []).length; for (const match of line.matchAll(pattern)) { keys.push(match[1]); } if (braceDepth <= 0) { break; } } if (keys.length === 0) { throw new Error("missing switchkins config alias keys in cnc_sim_api.cpp"); } for (const key of keys) { console.log(key); } JS else echo "missing python3 or node for switchkins remap alias extraction" >&2 exit 1 fi } validate_switchkins_case_fields_against_api() { local api_keys_csv=$1 local cpp_file=$2 local json_file=$3 local label=$4 if [[ -z "$api_keys_csv" ]]; then echo "missing switchkins config alias keys in core/src/cnc_sim_api.cpp" >&2 exit 1 fi if command -v python3 >/dev/null 2>&1; then if ! python3 - "$api_keys_csv" "$cpp_file" "$json_file" "$label" <<'PY' import json import re import sys api_keys_csv, cpp_file, json_file, label = sys.argv[1:] allowed = {key for key in api_keys_csv.split(",") if key} if not allowed: raise SystemExit("missing switchkins config alias keys in cnc_sim_api.cpp") row_re = re.compile( r'^\s*\{("(?:(?:\\.)|[^"\\])*"),\s*' r'("(?:(?:\\.)|[^"\\])*"),\s*' r'(-?\d+),\s*(-?\d+),\s*(-?\d+)\},?\s*$' ) def load_cases(path): if path.endswith(".json"): with open(path, encoding="utf-8") as handle: return json.load(handle) with open(path, encoding="utf-8") as handle: return [ { "field": json.loads(match.group(1)), "value": json.loads(match.group(2)), "m428": int(match.group(3)), "m429": int(match.group(4)), "m430": int(match.group(5)), } for match in row_re.finditer(handle.read()) ] cases = load_cases(cpp_file) + load_cases(json_file) for index, entry in enumerate(cases): field = entry["field"] if field.lower() not in allowed: raise SystemExit(f"{label} case {index} uses unsupported switchkins field {field!r}") PY then exit 1 fi elif command -v node >/dev/null 2>&1; then if ! node - "$api_keys_csv" "$cpp_file" "$json_file" "$label" <<'JS' const fs = require("fs"); const [apiKeysCsv, cppFile, jsonFile, label] = process.argv.slice(2); const allowed = new Set(apiKeysCsv.split(",").filter(Boolean)); if (allowed.size === 0) { throw new Error("missing switchkins config alias keys in cnc_sim_api.cpp"); } const rowRe = /^\s*\{("(?:(?:\\.)|[^"\\])*"),\s*("(?:(?:\\.)|[^"\\])*"),\s*(-?\d+),\s*(-?\d+),\s*(-?\d+)\},?\s*$/; const cases = []; for (const [path, isJson] of [[cppFile, false], [jsonFile, true]]) { if (isJson) { for (const entry of JSON.parse(fs.readFileSync(path, "utf8"))) { cases.push(entry); } continue; } for (const line of fs.readFileSync(path, "utf8").split(/\r?\n/)) { const match = rowRe.exec(line); if (!match) { continue; } cases.push({ field: JSON.parse(match[1]), value: JSON.parse(match[2]), m428: Number(match[3]), m429: Number(match[4]), m430: Number(match[5]), }); } } cases.forEach((entry, index) => { if (!allowed.has(entry.field.toLowerCase())) { throw new Error(`${label} case ${index} uses unsupported switchkins field ${JSON.stringify(entry.field)}`); } }); JS then exit 1 fi else echo "missing python3 or node for switchkins remap API alias validation" >&2 exit 1 fi } validate_case_values_against_table() { local table_file=$1 local cpp_file=$2 local json_file=$3 local label=$4 if command -v python3 >/dev/null 2>&1; then if ! python3 - "$table_file" "$cpp_file" "$json_file" "$label" <<'PY' import json import re import sys table_file, cpp_file, json_file, label = sys.argv[1:] table_row_re = re.compile( r'^\s*\{("(?:(?:\\.)|[^"\\])*"),\s*' r'(-?\d+),\s*(-?\d+),\s*(-?\d+)\},?\s*$' ) case_row_re = re.compile( r'^\s*\{("(?:(?:\\.)|[^"\\])*"),\s*' r'("(?:(?:\\.)|[^"\\])*"),\s*' r'(-?\d+),\s*(-?\d+),\s*(-?\d+)\},?\s*$' ) aliases = {} with open(table_file, encoding="utf-8") as handle: for line in handle: match = table_row_re.match(line) if not match: continue aliases[json.loads(match.group(1))] = ( int(match.group(2)), int(match.group(3)), int(match.group(4)), ) if not aliases: raise SystemExit(f"{label} table has no aliases") case_entries = [] with open(cpp_file, encoding="utf-8") as handle: for line_number, line in enumerate(handle, 1): match = case_row_re.match(line) if not match: continue case_entries.append(( f"{cpp_file}:{line_number}", json.loads(match.group(1)), json.loads(match.group(2)), (int(match.group(3)), int(match.group(4)), int(match.group(5))), )) with open(json_file, encoding="utf-8") as handle: for index, entry in enumerate(json.load(handle)): case_entries.append(( f"{json_file}:{index}", entry["field"], entry["value"], (entry["m428"], entry["m429"], entry["m430"]), )) for location, field, value, types in case_entries: normalized = "".join(ch for ch in value.lower() if not ch.isspace()) if normalized not in aliases: raise SystemExit(f"{label} case value {value!r} at {location} is not covered by the alias table") if aliases[normalized] != types: raise SystemExit( f"{label} case {field}={value!r} at {location} has kinstypes " f"{types}, but alias table has {aliases[normalized]}" ) PY then exit 1 fi elif command -v node >/dev/null 2>&1; then if ! node - "$table_file" "$cpp_file" "$json_file" "$label" <<'JS' const fs = require("fs"); const [tableFile, cppFile, jsonFile, label] = process.argv.slice(2); const tableRowRe = /^\s*\{("(?:(?:\\.)|[^"\\])*"),\s*(-?\d+),\s*(-?\d+),\s*(-?\d+)\},?\s*$/; const caseRowRe = /^\s*\{("(?:(?:\\.)|[^"\\])*"),\s*("(?:(?:\\.)|[^"\\])*"),\s*(-?\d+),\s*(-?\d+),\s*(-?\d+)\},?\s*$/; const aliases = new Map(); for (const line of fs.readFileSync(tableFile, "utf8").split(/\r?\n/)) { const match = tableRowRe.exec(line); if (match) { aliases.set(JSON.parse(match[1]), [Number(match[2]), Number(match[3]), Number(match[4])]); } } if (aliases.size === 0) { throw new Error(`${label} table has no aliases`); } const caseEntries = []; for (const [index, line] of fs.readFileSync(cppFile, "utf8").split(/\r?\n/).entries()) { const match = caseRowRe.exec(line); if (!match) { continue; } caseEntries.push({ location: `${cppFile}:${index + 1}`, field: JSON.parse(match[1]), value: JSON.parse(match[2]), types: [Number(match[3]), Number(match[4]), Number(match[5])], }); } JSON.parse(fs.readFileSync(jsonFile, "utf8")).forEach((entry, index) => { caseEntries.push({ location: `${jsonFile}:${index}`, field: entry.field, value: entry.value, types: [entry.m428, entry.m429, entry.m430], }); }); for (const entry of caseEntries) { const { location, field, value, types } = entry; const normalized = value.toLowerCase().replace(/\s+/g, ""); if (!aliases.has(normalized)) { throw new Error(`${label} case value ${JSON.stringify(value)} at ${location} is not covered by the alias table`); } const aliasTypes = aliases.get(normalized); if (JSON.stringify(aliasTypes) !== JSON.stringify(types)) { throw new Error(`${label} case ${field}=${JSON.stringify(value)} at ${location} has kinstypes ${JSON.stringify(types)}, but alias table has ${JSON.stringify(aliasTypes)}`); } } JS then exit 1 fi else echo "missing python3 or node for switchkins remap case/table validation" >&2 exit 1 fi } api_keys_csv=$(printf '%s,' $(extract_api_switchkins_alias_keys core/src/cnc_sim_api.cpp)) api_keys_csv=${api_keys_csv%,} validate_api_switchkins_alias_keys() { local keys_csv=$1 if command -v python3 >/dev/null 2>&1; then if ! python3 - "$keys_csv" <<'PY' import sys keys = [key for key in sys.argv[1].split(",") if key] if not keys: raise SystemExit("missing switchkins config alias keys in cnc_sim_api.cpp") seen = set() for key in keys: if key != key.lower(): raise SystemExit(f"switchkins config alias key is not lowercase: {key}") if any(ch.isspace() for ch in key): raise SystemExit(f"switchkins config alias key contains whitespace: {key}") if key in seen: raise SystemExit(f"duplicate switchkins config alias key in cnc_sim_api.cpp: {key}") seen.add(key) PY then exit 1 fi elif command -v node >/dev/null 2>&1; then if ! node - "$keys_csv" <<'JS' const keys = process.argv[2].split(",").filter(Boolean); if (keys.length === 0) { throw new Error("missing switchkins config alias keys in cnc_sim_api.cpp"); } const seen = new Set(); for (const key of keys) { if (key !== key.toLowerCase()) { throw new Error(`switchkins config alias key is not lowercase: ${key}`); } if (/\s/.test(key)) { throw new Error(`switchkins config alias key contains whitespace: ${key}`); } if (seen.has(key)) { throw new Error(`duplicate switchkins config alias key in cnc_sim_api.cpp: ${key}`); } seen.add(key); } JS then exit 1 fi else echo "missing python3 or node for switchkins remap API key validation" >&2 exit 1 fi } validate_api_switchkins_alias_keys "$api_keys_csv" for generated_file in \ linuxcnc_switchkins_remap_table.inc \ linuxcnc_switchkins_remap_table.stdout.inc \ linuxcnc_switchkins_remap_config_cases.inc \ linuxcnc_switchkins_remap_config_cases.stdout.inc \ linuxcnc_switchkins_remap_config_cases.json \ linuxcnc_switchkins_remap_config_cases.stdout.json do if [[ ! -f "$generated_dir/$generated_file" ]]; then echo "missing generated switchkins remap output: $generated_file" >&2 exit 1 fi if [[ ! -s "$generated_dir/$generated_file" ]]; then echo "empty generated switchkins remap output: $generated_file" >&2 exit 1 fi done diff -u "$generated_dir/linuxcnc_switchkins_remap_table.inc" \ "$generated_dir/linuxcnc_switchkins_remap_table.stdout.inc" diff -u "$generated_dir/linuxcnc_switchkins_remap_config_cases.inc" \ "$generated_dir/linuxcnc_switchkins_remap_config_cases.stdout.inc" diff -u "$generated_dir/linuxcnc_switchkins_remap_config_cases.json" \ "$generated_dir/linuxcnc_switchkins_remap_config_cases.stdout.json" validate_generated_header \ "$generated_dir/linuxcnc_switchkins_remap_table.inc" \ "table" \ "// Generated by ./generate-linuxcnc-switchkins-remap-table.sh." validate_generated_header \ core/src/linuxcnc_switchkins_remap_table.inc \ "tracked table" \ "// Generated by ./generate-linuxcnc-switchkins-remap-table.sh." validate_generated_header \ "$generated_dir/linuxcnc_switchkins_remap_config_cases.inc" \ "config cases" \ "// Generated by ./generate-linuxcnc-switchkins-remap-table.sh --config-cases." validate_generated_header \ core/tests/linuxcnc_switchkins_remap_config_cases.inc \ "tracked config cases" \ "// Generated by ./generate-linuxcnc-switchkins-remap-table.sh --config-cases." validate_json "$generated_dir/linuxcnc_switchkins_remap_config_cases.json" "generated linuxcnc_switchkins_remap_config_cases.json" validate_json "$generated_dir/linuxcnc_switchkins_remap_config_cases.stdout.json" "generated stdout linuxcnc_switchkins_remap_config_cases.json" validate_json web/public/linuxcnc_switchkins_remap_config_cases.json "web/public/linuxcnc_switchkins_remap_config_cases.json" validate_cpp_table "$generated_dir/linuxcnc_switchkins_remap_table.inc" "generated" validate_cpp_table core/src/linuxcnc_switchkins_remap_table.inc "tracked" validate_cpp_cases "$generated_dir/linuxcnc_switchkins_remap_config_cases.inc" "generated" validate_cpp_cases core/tests/linuxcnc_switchkins_remap_config_cases.inc "tracked" generated_table_rows=$(count_table_rows "$generated_dir/linuxcnc_switchkins_remap_table.inc") if ((generated_table_rows == 0)); then echo "empty generated switchkins remap table rows" >&2 exit 1 fi generated_cpp_cases=$(count_cpp_config_cases "$generated_dir/linuxcnc_switchkins_remap_config_cases.inc") generated_json_cases=$(count_json_cases "$generated_dir/linuxcnc_switchkins_remap_config_cases.json") tracked_cpp_cases=$(count_cpp_config_cases core/tests/linuxcnc_switchkins_remap_config_cases.inc) tracked_json_cases=$(count_json_cases web/public/linuxcnc_switchkins_remap_config_cases.json) if [[ "$generated_cpp_cases" != "$generated_json_cases" ]]; then echo "generated switchkins remap C++/JSON case count mismatch: $generated_cpp_cases != $generated_json_cases" >&2 exit 1 fi if [[ "$tracked_cpp_cases" != "$tracked_json_cases" ]]; then echo "tracked switchkins remap C++/JSON case count mismatch: $tracked_cpp_cases != $tracked_json_cases" >&2 exit 1 fi compare_cpp_json_cases \ "$generated_dir/linuxcnc_switchkins_remap_config_cases.inc" \ "$generated_dir/linuxcnc_switchkins_remap_config_cases.json" \ "generated" compare_cpp_json_cases \ core/tests/linuxcnc_switchkins_remap_config_cases.inc \ web/public/linuxcnc_switchkins_remap_config_cases.json \ "tracked" validate_switchkins_case_fields_against_api \ "$api_keys_csv" \ "$generated_dir/linuxcnc_switchkins_remap_config_cases.inc" \ "$generated_dir/linuxcnc_switchkins_remap_config_cases.json" \ "generated" validate_case_values_against_table \ "$generated_dir/linuxcnc_switchkins_remap_table.inc" \ "$generated_dir/linuxcnc_switchkins_remap_config_cases.inc" \ "$generated_dir/linuxcnc_switchkins_remap_config_cases.json" \ "generated" validate_switchkins_case_fields_against_api \ "$api_keys_csv" \ core/tests/linuxcnc_switchkins_remap_config_cases.inc \ web/public/linuxcnc_switchkins_remap_config_cases.json \ "tracked" validate_case_values_against_table \ core/src/linuxcnc_switchkins_remap_table.inc \ core/tests/linuxcnc_switchkins_remap_config_cases.inc \ web/public/linuxcnc_switchkins_remap_config_cases.json \ "tracked" diff -u core/src/linuxcnc_switchkins_remap_table.inc "$generated_dir/linuxcnc_switchkins_remap_table.inc" diff -u core/tests/linuxcnc_switchkins_remap_config_cases.inc "$generated_dir/linuxcnc_switchkins_remap_config_cases.inc" diff -u web/public/linuxcnc_switchkins_remap_config_cases.json "$generated_dir/linuxcnc_switchkins_remap_config_cases.json"