diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bfa5fc6d..a09b0399 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,11 +16,22 @@ jobs: - uses: extractions/setup-just@v2 - - name: Set up Homebrew - uses: Homebrew/actions/setup-homebrew@master + - uses: actions/setup-node@v4 + with: + node-version: '22' - name: Install Tongues - run: brew install ldayton/tongues/tongues + env: + TONGUES_VERSION: v0.2.3 + run: | + mkdir -p "$HOME/.local/lib/tongues/lib" "$HOME/.local/lib/tongues/bin" "$HOME/.local/bin" + curl -sSfL "https://github.com/ldayton/Tongues/releases/download/${TONGUES_VERSION}/tongues.js" \ + -o "$HOME/.local/lib/tongues/lib/tongues.js" + curl -sSfL "https://raw.githubusercontent.com/ldayton/Tongues/${TONGUES_VERSION}/tongues/bin/tongues.js" \ + -o "$HOME/.local/lib/tongues/bin/tongues.js" + printf '#!/bin/sh\nexec node %s/.local/lib/tongues/bin/tongues.js "$@"\n' "$HOME" > "$HOME/.local/bin/tongues" + chmod +x "$HOME/.local/bin/tongues" + echo "$HOME/.local/bin" >> "$GITHUB_PATH" - name: Job summary run: | diff --git a/.gitignore b/.gitignore index beac105f..c00a55af 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ tools/bash-oracle/bin/negation-cmdsub.txt /bugs.tests scalene-profile.html src/cmd/debug-test/ +.out/ diff --git a/justfile b/justfile index a7f046fe..2142ed7d 100644 --- a/justfile +++ b/justfile @@ -1,5 +1,7 @@ set shell := ["bash", "-o", "pipefail", "-cu"] +export VIRTUAL_ENV := "" + # --- Configuration --- project := "parable" run_id := `head -c 16 /dev/urandom | xxd -p` @@ -36,7 +38,7 @@ src-verify-lock: (_banner "src-verify-lock") check-tongues: (_banner "check-tongues") #!/usr/bin/env bash set -euo pipefail - required="0.2.2" + required="0.2.3" if ! command -v tongues &>/dev/null; then echo "FAIL: tongues not found. Install with: brew install ldayton/tap/tongues" exit 1 @@ -68,6 +70,102 @@ check: _check-parallel [group: 'ci'] check-quick: src-test +# --- Transpiled backends --- + +# SHA256 of source file (truncated to 16 chars) +[private] +_src-checksum: + @cat src/parable.py | { sha256sum 2>/dev/null || shasum -a 256; } | cut -c1-16 + +# Transpile source to target language (skips if unchanged) +[private] +_transpile target: + #!/usr/bin/env bash + set -euo pipefail + declare -A ext=([python]=py [ruby]=rb [perl]=pl [javascript]=js [java]=java) + e=${ext[{{target}}]} + out=".out/parable.$e" + sum_file=".out/.sum-{{target}}" + current=$(just -f {{justfile()}} _src-checksum) + cached=$(cat "$sum_file" 2>/dev/null || echo "") + if [ -f "$out" ] && [ "$current" = "$cached" ]; then + printf '\033[33m[transpile-{{target}}] up to date\033[0m\n' + exit 0 + fi + rm -f "$sum_file" + printf '\033[32m[transpile-{{target}}]\033[0m\n' + start=$SECONDS + mkdir -p .out + tongues --target {{target}} -o "$out" src/parable.py + case "{{target}}" in + javascript) echo 'module.exports = { parse, ParseError, MatchedPairError };' >> "$out" ;; + perl) echo '1;' >> "$out" ;; + esac + printf '\033[32m[transpile-{{target}}] %ds\033[0m\n' "$((SECONDS - start))" + echo "$current" > "$sum_file" + +# Run transpiled tests for a target language +[private] +_run-lang-tests target: + #!/usr/bin/env bash + set -euo pipefail + printf '\033[32m[lang-{{target}}]\033[0m\n' + start=$SECONDS + case "{{target}}" in + javascript) + PARABLE_PATH="$(pwd)/.out" node tests/transpiled/javascript/run-tests.js tests + ;; + python) + PYTHONPATH=.out uv run python tests/transpiled/python/run_tests.py tests + ;; + java) + mkdir -p .out/java-classes + cp .out/parable.java .out/java-classes/Main.java + javac -encoding UTF-8 .out/java-classes/Main.java -d .out/java-classes + javac -encoding UTF-8 -cp .out/java-classes tests/transpiled/java/RunTests.java -d .out/java-classes + java -cp .out/java-classes RunTests tests + ;; + perl) + perl -I.out tests/transpiled/perl/run_tests.pl tests + ;; + ruby) + ruby -I.out tests/transpiled/ruby/run_tests.rb tests + ;; + *) + echo "Unknown target: {{target}}" + exit 1 + ;; + esac + printf '\033[32m[lang-{{target}}] %ds\033[0m\n' "$((SECONDS - start))" + +# Transpile and run tests in target language +# Usage: just lang javascript +[group: 'backends'] +lang target: check-tongues (_transpile target) (_run-lang-tests target) + +# Transpile using local Tongues dev source and run tests +# Usage: just lang-dev javascript +[group: 'backends'] +lang-dev target: (_transpile-dev target) (_run-lang-tests target) + +# Transpile using local Tongues source (no caching) +[private] +_transpile-dev target: + #!/usr/bin/env bash + set -euo pipefail + declare -A ext=([python]=py [ruby]=rb [perl]=pl [javascript]=js [java]=java) + e=${ext[{{target}}]} + out=".out/parable.$e" + mkdir -p .out + printf '\033[32m[transpile-{{target}} (dev)]\033[0m\n' + start=$SECONDS + python3 ~/source/Tongues/tongues/bin/tongues --target {{target}} -o "$out" src/parable.py + case "{{target}}" in + javascript) echo 'module.exports = { parse, ParseError, MatchedPairError };' >> "$out" ;; + perl) echo '1;' >> "$out" ;; + esac + printf '\033[32m[transpile-{{target}} (dev)] %ds\033[0m\n' "$((SECONDS - start))" + # --- Tools --- # Run the fuzzer (e.g., just fuzz char --stop-after 10) diff --git a/tests/transpiled/java/RunTests.java b/tests/transpiled/java/RunTests.java new file mode 100644 index 00000000..16fc9fcc --- /dev/null +++ b/tests/transpiled/java/RunTests.java @@ -0,0 +1,259 @@ +import java.io.*; +import java.nio.file.*; +import java.util.*; +import java.util.concurrent.*; +import java.util.regex.*; + +public class RunTests { + static class TestCase { + String name; + String input; + String expected; + int lineNum; + TestCase(String name, String input, String expected, int lineNum) { + this.name = name; + this.input = input; + this.expected = expected; + this.lineNum = lineNum; + } + } + + static class TestResult { + String relPath; + int lineNum; + String name; + String input; + String expected; + String actual; + String err; + TestResult(String relPath, int lineNum, String name, String input, String expected, String actual, String err) { + this.relPath = relPath; + this.lineNum = lineNum; + this.name = name; + this.input = input; + this.expected = expected; + this.actual = actual; + this.err = err; + } + } + + static List findTestFiles(Path directory) throws IOException { + List result = new ArrayList<>(); + Files.walk(directory) + .filter(p -> !Files.isDirectory(p) && p.toString().endsWith(".tests")) + .sorted() + .forEach(result::add); + return result; + } + + static List parseTestFile(Path filepath) throws IOException { + List lines = Files.readAllLines(filepath); + List tests = new ArrayList<>(); + int i = 0; + int n = lines.size(); + while (i < n) { + String line = lines.get(i); + if (line.startsWith("#") || line.trim().isEmpty()) { + i++; + continue; + } + if (line.startsWith("=== ")) { + String name = line.substring(4).trim(); + int startLine = i + 1; + i++; + List inputLines = new ArrayList<>(); + while (i < n && !lines.get(i).equals("---")) { + inputLines.add(lines.get(i)); + i++; + } + if (i < n && lines.get(i).equals("---")) { + i++; + } + List expectedLines = new ArrayList<>(); + while (i < n && !lines.get(i).equals("---") && !lines.get(i).startsWith("=== ")) { + expectedLines.add(lines.get(i)); + i++; + } + if (i < n && lines.get(i).equals("---")) { + i++; + } + while (!expectedLines.isEmpty() && expectedLines.get(expectedLines.size() - 1).trim().isEmpty()) { + expectedLines.remove(expectedLines.size() - 1); + } + String testInput = String.join("\n", inputLines); + String testExpected = String.join("\n", expectedLines); + tests.add(new TestCase(name, testInput, testExpected, startLine)); + } else { + i++; + } + } + return tests; + } + + static final Pattern WHITESPACE_RE = Pattern.compile("[\\s\\v]+"); + + static String normalize(String s) { + return WHITESPACE_RE.matcher(s).replaceAll(" ").trim(); + } + + static ExecutorService executor = Executors.newSingleThreadExecutor(); + + static String[] runTest(String testInput, String testExpected) { + final boolean extglob; + final String finalInput; + if (testInput.startsWith("# @extglob\n")) { + extglob = true; + finalInput = testInput.substring("# @extglob\n".length()); + } else { + extglob = false; + finalInput = testInput; + } + Future future = executor.submit(() -> { + try { + List nodes = Main.parse(finalInput, extglob); + List parts = new ArrayList<>(); + for (Node n : nodes) { + parts.add(n.toSexp()); + } + String actual = String.join(" ", parts); + String expectedNorm = normalize(testExpected); + if (expectedNorm.equals("")) { + return new String[]{"false", actual, "Expected parse error but got successful parse"}; + } + String actualNorm = normalize(actual); + if (expectedNorm.equals(actualNorm)) { + return new String[]{"true", actual, ""}; + } + return new String[]{"false", actual, ""}; + } catch (RuntimeException e) { + if (normalize(testExpected).equals("")) { + return new String[]{"true", "", ""}; + } + StringWriter sw = new StringWriter(); + e.printStackTrace(new PrintWriter(sw)); + return new String[]{"false", "", e.getMessage() + "\n" + sw.toString()}; + } + }); + try { + return future.get(10, TimeUnit.SECONDS); + } catch (TimeoutException e) { + future.cancel(true); + return new String[]{"false", "", "Test timed out after 10 seconds"}; + } catch (Exception e) { + return new String[]{"false", "", e.getMessage()}; + } + } + + static void printUsage() { + System.out.println("Usage: RunTests [options] [test_dir]"); + System.out.println("Options:"); + System.out.println(" -v, --verbose Show PASS/FAIL for each test"); + System.out.println(" -f, --filter PAT Only run tests matching PAT"); + System.out.println(" --max-failures N Show at most N failures (0=unlimited, default=20)"); + System.out.println(" -h, --help Show this help message"); + } + + public static void main(String[] args) throws IOException { + boolean verbose = false; + String filterPattern = null; + String testDir = null; + int maxFailures = 20; + + for (int i = 0; i < args.length; i++) { + if (args[i].equals("-h") || args[i].equals("--help")) { + printUsage(); + return; + } else if (args[i].equals("-v") || args[i].equals("--verbose")) { + verbose = true; + } else if ((args[i].equals("-f") || args[i].equals("--filter")) && i + 1 < args.length) { + filterPattern = args[++i]; + } else if (args[i].equals("--max-failures") && i + 1 < args.length) { + maxFailures = Integer.parseInt(args[++i]); + } else if (!args[i].startsWith("-")) { + testDir = args[i]; + } + } + + if (testDir == null) { + testDir = "tests"; + if (!Files.exists(Path.of(testDir))) { + testDir = "../tests"; + } + } + + Path testPath = Path.of(testDir); + if (!Files.exists(testPath)) { + System.err.println("Could not find tests directory"); + System.exit(1); + } + + long startTime = System.currentTimeMillis(); + int totalPassed = 0; + int totalFailed = 0; + List failedTests = new ArrayList<>(); + + List testFiles = findTestFiles(testPath); + Path baseDir = testPath.toAbsolutePath().getParent(); + + for (Path fpath : testFiles) { + List tests = parseTestFile(fpath); + Path absFpath = fpath.toAbsolutePath(); + String relPath = baseDir.relativize(absFpath).toString(); + + for (TestCase tc : tests) { + if (filterPattern != null && !tc.name.contains(filterPattern) && !relPath.contains(filterPattern)) { + continue; + } + String effectiveExpected = tc.expected; + if (normalize(tc.expected).equals("")) { + effectiveExpected = ""; + } + String[] result = runTest(tc.input, effectiveExpected); + boolean passed = result[0].equals("true"); + String actual = result[1]; + String errMsg = result[2]; + + if (passed) { + totalPassed++; + if (verbose) { + System.out.printf("PASS %s:%d %s%n", relPath, tc.lineNum, tc.name); + } + } else { + totalFailed++; + failedTests.add(new TestResult(relPath, tc.lineNum, tc.name, tc.input, tc.expected, actual, errMsg)); + if (verbose) { + System.out.printf("FAIL %s:%d %s%n", relPath, tc.lineNum, tc.name); + } + } + } + } + + double elapsed = (System.currentTimeMillis() - startTime) / 1000.0; + + if (totalFailed > 0) { + System.out.println("============================================================"); + System.out.println("FAILURES"); + System.out.println("============================================================"); + int showCount = maxFailures == 0 ? failedTests.size() : Math.min(failedTests.size(), maxFailures); + for (int i = 0; i < showCount; i++) { + TestResult f = failedTests.get(i); + System.out.printf("%n%s:%d %s%n", f.relPath, f.lineNum, f.name); + System.out.printf(" Input: %s%n", f.input.replace("\n", "\\n")); + System.out.printf(" Expected: %s%n", f.expected); + System.out.printf(" Actual: %s%n", f.actual); + if (!f.err.isEmpty()) { + System.out.printf(" Error: %s%n", f.err); + } + } + if (maxFailures > 0 && totalFailed > maxFailures) { + System.out.printf("%n... and %d more failures%n", totalFailed - maxFailures); + } + } + + System.out.printf("java: %d passed, %d failed in %.2fs%n", totalPassed, totalFailed, elapsed); + executor.shutdownNow(); + if (totalFailed > 0) { + System.exit(1); + } + } +} diff --git a/tests/transpiled/javascript/run-tests.js b/tests/transpiled/javascript/run-tests.js new file mode 100644 index 00000000..adcf07df --- /dev/null +++ b/tests/transpiled/javascript/run-tests.js @@ -0,0 +1,295 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const path = require('path'); +const { Worker, isMainThread, parentPort, workerData } = require('worker_threads'); + +const parablePath = process.env.PARABLE_PATH || path.join(__dirname, '..', '..', '..', 'dist'); + +// Worker thread: runs tests sequentially, responds to messages +if (!isMainThread) { + const { parse, ParseError } = require(path.join(workerData.parablePath, 'parable.js')); + + parentPort.on('message', (msg) => { + let testInput = msg.testInput; + const testExpected = msg.testExpected; + let extglob = false; + if (testInput.startsWith('# @extglob\n')) { + extglob = true; + testInput = testInput.slice('# @extglob\n'.length); + } + try { + const nodes = parse(testInput, extglob); + const actual = nodes.map(n => n.to_sexp()).join(' '); + parentPort.postMessage({ actual, error: null, parseSuccess: true }); + } catch (e) { + if (e instanceof ParseError) { + parentPort.postMessage({ actual: '', error: e.message, parseSuccess: false, isParseError: true }); + } else { + parentPort.postMessage({ actual: '', error: e.message + '\n' + e.stack, parseSuccess: false, isParseError: false }); + } + } + }); +} else { + +const { parse, ParseError } = require(path.join(parablePath, 'parable.js')); + +function createWorker() { + return new Worker(__filename, { workerData: { parablePath } }); +} + +let worker = createWorker(); +let pendingResolve = null; +let timeoutId = null; + +worker.on('message', (msg) => { + if (pendingResolve) { + clearTimeout(timeoutId); + pendingResolve(msg); + pendingResolve = null; + } +}); + +worker.on('error', (err) => { + if (pendingResolve) { + clearTimeout(timeoutId); + pendingResolve({ actual: '', error: err.message, parseSuccess: false, isParseError: false }); + pendingResolve = null; + } +}); + +function runTestWithTimeout(testInput, testExpected) { + return new Promise((resolve) => { + const handleResult = (msg) => { + const expectedNorm = normalize(testExpected); + if (msg.parseSuccess) { + if (expectedNorm === '') { + resolve({ passed: false, actual: msg.actual, error: 'Expected parse error but got successful parse' }); + } else if (expectedNorm === normalize(msg.actual)) { + resolve({ passed: true, actual: msg.actual, error: null }); + } else { + resolve({ passed: false, actual: msg.actual, error: null }); + } + } else { + if (msg.isParseError && expectedNorm === '') { + resolve({ passed: true, actual: '', error: null }); + } else { + resolve({ passed: false, actual: msg.actual, error: msg.error }); + } + } + }; + + pendingResolve = handleResult; + timeoutId = setTimeout(() => { + pendingResolve = null; + worker.terminate(); + worker = createWorker(); + worker.on('message', (msg) => { + if (pendingResolve) { + clearTimeout(timeoutId); + pendingResolve(msg); + pendingResolve = null; + } + }); + worker.on('error', (err) => { + if (pendingResolve) { + clearTimeout(timeoutId); + pendingResolve({ actual: '', error: err.message, parseSuccess: false, isParseError: false }); + pendingResolve = null; + } + }); + resolve({ passed: false, actual: '', error: 'Test timed out after 10 seconds' }); + }, 10000); + + worker.postMessage({ testInput, testExpected }); + }); +} + +function findTestFiles(directory) { + const result = []; + function walk(dir) { + const files = fs.readdirSync(dir); + for (const f of files) { + const fullPath = path.join(dir, f); + const stat = fs.statSync(fullPath); + if (stat.isDirectory()) { + walk(fullPath); + } else if (f.endsWith('.tests')) { + result.push(fullPath); + } + } + } + walk(directory); + result.sort(); + return result; +} + +function parseTestFile(filepath) { + const tests = []; + const lines = fs.readFileSync(filepath, 'utf8').split('\n'); + let i = 0; + const n = lines.length; + while (i < n) { + const line = lines[i]; + if (line.startsWith('#') || line.trim() === '') { + i++; + continue; + } + if (line.startsWith('=== ')) { + const name = line.slice(4).trim(); + const startLine = i + 1; + i++; + const inputLines = []; + while (i < n && lines[i] !== '---') { + inputLines.push(lines[i]); + i++; + } + if (i < n && lines[i] === '---') { + i++; + } + const expectedLines = []; + while (i < n && lines[i] !== '---' && !lines[i].startsWith('=== ')) { + expectedLines.push(lines[i]); + i++; + } + if (i < n && lines[i] === '---') { + i++; + } + while (expectedLines.length > 0 && expectedLines[expectedLines.length - 1].trim() === '') { + expectedLines.pop(); + } + const testInput = inputLines.join('\n'); + const testExpected = expectedLines.join('\n'); + tests.push({ name, input: testInput, expected: testExpected, lineNum: startLine }); + } else { + i++; + } + } + return tests; +} + +function normalize(s) { + return s.split(/\s+/).join(' ').trim(); +} + +function printUsage() { + console.log('Usage: run-tests.js [options] '); + console.log('Options:'); + console.log(' -v, --verbose Show PASS/FAIL for each test'); + console.log(' -f, --filter PAT Only run tests matching PAT'); + console.log(' --max-failures N Show at most N failures (0=unlimited, default=20)'); + console.log(' -h, --help Show this help message'); +} + +async function main() { + if (process.argv.includes('-h') || process.argv.includes('--help')) { + printUsage(); + process.exit(0); + } + + const verbose = process.argv.includes('-v') || process.argv.includes('--verbose'); + let filterPattern = null; + let filterIdx = process.argv.indexOf('-f'); + if (filterIdx === -1) filterIdx = process.argv.indexOf('--filter'); + if (filterIdx !== -1 && process.argv[filterIdx + 1]) { + filterPattern = process.argv[filterIdx + 1]; + } + let maxFailures = 20; + const maxFailuresIdx = process.argv.indexOf('--max-failures'); + if (maxFailuresIdx !== -1 && process.argv[maxFailuresIdx + 1]) { + maxFailures = parseInt(process.argv[maxFailuresIdx + 1], 10); + } + + let testDir = null; + const skipNext = new Set(); + for (let i = 2; i < process.argv.length; i++) { + if (skipNext.has(i)) continue; + const arg = process.argv[i]; + if (arg === '-f' || arg === '--filter' || arg === '--max-failures') { + skipNext.add(i + 1); + continue; + } + if (arg.startsWith('-')) continue; + testDir = arg; + break; + } + + if (!testDir) { + console.error('Error: test_dir is required'); + printUsage(); + process.exit(1); + } + + if (!fs.existsSync(testDir)) { + console.error(`Error: ${testDir} does not exist`); + process.exit(1); + } + + const startTime = Date.now(); + let totalPassed = 0; + let totalFailed = 0; + const failedTests = []; + + const baseDir = path.dirname(path.resolve(testDir)); + const testFiles = fs.statSync(testDir).isFile() ? [testDir] : findTestFiles(testDir); + + for (const filepath of testFiles) { + const tests = parseTestFile(filepath); + const relPath = path.relative(baseDir, filepath); + + for (const { name, input, expected, lineNum } of tests) { + if (filterPattern && !name.includes(filterPattern) && !relPath.includes(filterPattern)) { + continue; + } + + const effectiveExpected = normalize(expected) === '' ? '' : expected; + const { passed, actual, error } = await runTestWithTimeout(input, effectiveExpected); + + if (passed) { + totalPassed++; + if (verbose) { + console.log(`PASS ${relPath}:${lineNum} ${name}`); + } + } else { + totalFailed++; + failedTests.push({ relPath, lineNum, name, input, expected, actual, error }); + if (verbose) { + console.log(`FAIL ${relPath}:${lineNum} ${name}`); + } + } + } + } + + const elapsed = ((Date.now() - startTime) / 1000).toFixed(2); + + // Clean up worker + worker.terminate(); + + if (totalFailed > 0) { + const showCount = maxFailures === 0 ? failedTests.length : Math.min(failedTests.length, maxFailures); + console.log('='.repeat(60)); + console.log('FAILURES'); + console.log('='.repeat(60)); + for (const { relPath, lineNum, name, input, expected, actual, error } of failedTests.slice(0, showCount)) { + console.log(`\n${relPath}:${lineNum} ${name}`); + console.log(` Input: ${JSON.stringify(input)}`); + console.log(` Expected: ${expected}`); + console.log(` Actual: ${actual}`); + if (error) { + console.log(` Error: ${error}`); + } + } + if (maxFailures > 0 && totalFailed > maxFailures) { + console.log(`\n... and ${totalFailed - maxFailures} more failures`); + } + } + + const lang = process.env.PARABLE_LANG || 'javascript'; + console.log(`${lang}: ${totalPassed} passed, ${totalFailed} failed in ${elapsed}s`); + + process.exit(totalFailed > 0 ? 1 : 0); +} + +main(); + +} // end isMainThread diff --git a/tests/transpiled/perl/run_tests.pl b/tests/transpiled/perl/run_tests.pl new file mode 100644 index 00000000..aa2a0a63 --- /dev/null +++ b/tests/transpiled/perl/run_tests.pl @@ -0,0 +1,217 @@ +#!/usr/bin/env perl +# Test runner for Perl backend + +use strict; +use warnings; +use feature 'signatures'; +no warnings 'experimental::signatures'; +use File::Find; +use File::Basename; +use Time::HiRes qw(time); + +require 'parable.pl'; + +sub find_test_files ($directory) { + my @result; + find(sub { + push @result, $File::Find::name if /\.tests$/; + }, $directory); + return sort @result; +} + +sub parse_test_file ($filepath) { + open my $fh, '<', $filepath or die "Cannot open $filepath: $!"; + my @lines = <$fh>; + close $fh; + chomp @lines; + + my @tests; + my $i = 0; + my $n = scalar @lines; + + while ($i < $n) { + my $line = $lines[$i]; + if ($line =~ /^#/ || $line =~ /^\s*$/) { + $i++; + next; + } + if ($line =~ /^=== (.*)/) { + my $name = $1; + my $start_line = $i + 1; + $i++; + my @input_lines; + while ($i < $n && $lines[$i] ne '---') { + push @input_lines, $lines[$i]; + $i++; + } + $i++ if $i < $n && $lines[$i] eq '---'; + my @expected_lines; + while ($i < $n && $lines[$i] ne '---' && $lines[$i] !~ /^=== /) { + push @expected_lines, $lines[$i]; + $i++; + } + $i++ if $i < $n && $lines[$i] eq '---'; + pop @expected_lines while @expected_lines && $expected_lines[-1] =~ /^\s*$/; + + my $test_input = join("\n", @input_lines); + my $test_expected = join("\n", @expected_lines); + push @tests, [$name, $test_input, $test_expected, $start_line]; + } else { + $i++; + } + } + return @tests; +} + +sub normalize ($s) { + $s =~ s/\s+/ /g; + $s =~ s/^\s+//; + $s =~ s/\s+$//; + return $s; +} + +sub run_test ($test_input, $test_expected) { + my $extglob = 0; + if ($test_input =~ s/^# \@extglob\n//) { + $extglob = 1; + } + + my $actual; + eval { + local $SIG{ALRM} = sub { die "timeout\n" }; + alarm(10); + my $nodes = main::parse($test_input, $extglob); + $actual = join(' ', map { $_->to_sexp() } @$nodes); + alarm(0); + }; + alarm(0); + if ($@) { + my $error = $@; + if ($error eq "timeout\n") { + return (0, '', 'Test timed out after 10 seconds'); + } + if (normalize($test_expected) eq '') { + return (1, '', undef); + } + return (0, '', $error); + } + + if (normalize($test_expected) eq '') { + return (0, $actual, 'Expected parse error but got successful parse'); + } + + my $expected_norm = normalize($test_expected); + my $actual_norm = normalize($actual); + if ($expected_norm eq $actual_norm) { + return (1, $actual, undef); + } else { + return (0, $actual, undef); + } +} + +sub print_usage { + print "Usage: run_tests [options] [test_dir]\n"; + print "Options:\n"; + print " -v, --verbose Show PASS/FAIL for each test\n"; + print " -f, --filter PAT Only run tests matching PAT\n"; + print " --max-failures N Show at most N failures (0=unlimited, default=20)\n"; + print " -h, --help Show this help message\n"; +} + +sub main { + my $test_dir; + my $verbose = 0; + my $filter_pattern; + my $max_failures = 20; + my $i = 0; + while ($i < @ARGV) { + my $arg = $ARGV[$i]; + if ($arg eq '-h' || $arg eq '--help') { + print_usage(); + exit(0); + } elsif ($arg eq '-v' || $arg eq '--verbose') { + $verbose = 1; + } elsif ($arg eq '-f' || $arg eq '--filter') { + $i++; + $filter_pattern = $ARGV[$i] if $i < @ARGV; + } elsif ($arg eq '--max-failures') { + $i++; + $max_failures = int($ARGV[$i]) if $i < @ARGV; + } elsif ($arg !~ /^-/) { + $test_dir = $arg; + } + $i++; + } + + die "Usage: run_tests \n" unless defined $test_dir && -e $test_dir; + + my $start_time = time(); + my $total_passed = 0; + my $total_failed = 0; + my @failed_tests; + + my @test_files; + if (-f $test_dir) { + @test_files = ($test_dir); + } else { + @test_files = find_test_files($test_dir); + } + + for my $filepath (@test_files) { + my @tests = parse_test_file($filepath); + my $rel_path = $filepath; + if ($rel_path =~ m{/tests/}) { + $rel_path =~ s{.*/tests/}{tests/}; + } + + for my $test (@tests) { + my ($name, $test_input, $test_expected, $line_num) = @$test; + + if (defined $filter_pattern) { + next unless $name =~ /\Q$filter_pattern\E/ || $rel_path =~ /\Q$filter_pattern\E/; + } + + my $effective_expected = $test_expected; + if (normalize($test_expected) eq '') { + $effective_expected = ''; + } + + my ($passed, $actual, $error_msg) = run_test($test_input, $effective_expected); + + if ($passed) { + $total_passed++; + print "PASS $rel_path:$line_num $name\n" if $verbose; + } else { + $total_failed++; + push @failed_tests, [$rel_path, $line_num, $name, $test_input, $test_expected, $actual, $error_msg]; + print "FAIL $rel_path:$line_num $name\n" if $verbose; + } + } + } + + my $elapsed = time() - $start_time; + + if ($total_failed > 0) { + print "=" x 60, "\n"; + print "FAILURES\n"; + print "=" x 60, "\n"; + my $show_count = $max_failures == 0 ? scalar(@failed_tests) : ($#failed_tests + 1 < $max_failures ? $#failed_tests + 1 : $max_failures); + for my $i (0 .. $show_count - 1) { + my $failure = $failed_tests[$i]; + my ($rel_path, $line_num, $name, $inp, $expected, $actual, $error_msg) = @$failure; + print "\n$rel_path:$line_num $name\n"; + print " Input: '$inp'\n"; + print " Expected: $expected\n"; + print " Actual: $actual\n"; + print " Error: $error_msg\n" if defined $error_msg; + } + if ($max_failures > 0 && $total_failed > $max_failures) { + print "\n... and " . ($total_failed - $max_failures) . " more failures\n"; + } + } + + printf "perl: %d passed, %d failed in %.2fs\n", $total_passed, $total_failed, $elapsed; + exit($total_failed > 0 ? 1 : 0); +} + +main(); diff --git a/tests/transpiled/python/run_tests.py b/tests/transpiled/python/run_tests.py new file mode 120000 index 00000000..3bae02dd --- /dev/null +++ b/tests/transpiled/python/run_tests.py @@ -0,0 +1 @@ +../../../src/run_tests.py \ No newline at end of file diff --git a/tests/transpiled/ruby/run_tests.rb b/tests/transpiled/ruby/run_tests.rb new file mode 100644 index 00000000..41e7f181 --- /dev/null +++ b/tests/transpiled/ruby/run_tests.rb @@ -0,0 +1,173 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require 'timeout' +require 'parable' + +def find_test_files(directory) + Dir.glob(File.join(directory, '**', '*.tests')).sort +end + +def parse_test_file(filepath) + tests = [] + lines = File.read(filepath).split("\n") + i = 0 + n = lines.length + while i < n + line = lines[i] + if line.start_with?('#') || line.strip.empty? + i += 1 + next + end + if line.start_with?('=== ') + name = line[4..].strip + start_line = i + 1 + i += 1 + input_lines = [] + while i < n && lines[i] != '---' + input_lines << lines[i] + i += 1 + end + i += 1 if i < n && lines[i] == '---' + expected_lines = [] + while i < n && lines[i] != '---' && !lines[i].start_with?('=== ') + expected_lines << lines[i] + i += 1 + end + i += 1 if i < n && lines[i] == '---' + expected_lines.pop while !expected_lines.empty? && expected_lines[-1].strip.empty? + tests << [name, input_lines.join("\n"), expected_lines.join("\n"), start_line] + else + i += 1 + end + end + tests +end + +def normalize(s) + s.encode('UTF-8', invalid: :replace, undef: :replace, replace: '?').split.join(' ') +end + +def run_test(test_input, test_expected) + extglob = false + if test_input.start_with?("# @extglob\n") + extglob = true + test_input = test_input[("# @extglob\n").length..] + end + begin + Timeout.timeout(10) do + nodes = parse(test_input, extglob) + actual = nodes.map(&:to_sexp).join(' ') + if normalize(test_expected) == '' + return [false, actual, 'Expected parse error but got successful parse'] + end + if normalize(test_expected) == normalize(actual) + return [true, actual, nil] + else + return [false, actual, nil] + end + end + rescue Timeout::Error + [false, '', 'Test timed out after 10 seconds'] + rescue ParseError => e + return [true, '', nil] if normalize(test_expected) == '' + [false, '', e.message] + rescue StandardError => e + [false, '', "#{e.class}: #{e.message}"] + end +end + +def print_usage + puts 'Usage: run_tests [options] ' + puts 'Options:' + puts ' -v, --verbose Show PASS/FAIL for each test' + puts ' -f, --filter PAT Only run tests matching PAT' + puts ' --max-failures N Show at most N failures (0=unlimited, default=20)' + puts ' -h, --help Show this help message' +end + +verbose = false +filter_pattern = nil +max_failures = 20 +test_dir = nil + +i = 0 +while i < ARGV.length + arg = ARGV[i] + case arg + when '-h', '--help' + print_usage + exit(0) + when '-v', '--verbose' + verbose = true + when '-f', '--filter' + i += 1 + filter_pattern = ARGV[i] if i < ARGV.length + when '--max-failures' + i += 1 + max_failures = ARGV[i].to_i if i < ARGV.length + else + test_dir = arg unless arg.start_with?('-') + end + i += 1 +end + +if test_dir.nil? + $stderr.puts 'Error: test_dir is required' + print_usage + exit(1) +end + +unless File.exist?(test_dir) + $stderr.puts "Error: #{test_dir} does not exist" + exit(1) +end + +start_time = Time.now +total_passed = 0 +total_failed = 0 +failed_tests = [] + +base_dir = File.dirname(File.expand_path(test_dir)) +test_files = File.file?(test_dir) ? [test_dir] : find_test_files(test_dir) + +test_files.each do |filepath| + tests = parse_test_file(filepath) + rel_path = filepath.sub("#{base_dir}/", '') + + tests.each do |name, test_input, test_expected, line_num| + next if filter_pattern && !name.include?(filter_pattern) && !rel_path.include?(filter_pattern) + + effective_expected = normalize(test_expected) == '' ? '' : test_expected + passed, actual, error_msg = run_test(test_input, effective_expected) + + if passed + total_passed += 1 + puts "PASS #{rel_path}:#{line_num} #{name}" if verbose + else + total_failed += 1 + failed_tests << [rel_path, line_num, name, test_input, test_expected, actual, error_msg] + puts "FAIL #{rel_path}:#{line_num} #{name}" if verbose + end + end +end + +elapsed = Time.now - start_time + +if total_failed > 0 + puts '=' * 60 + puts 'FAILURES' + puts '=' * 60 + show_count = max_failures == 0 ? failed_tests.length : [failed_tests.length, max_failures].min + failed_tests[0...show_count].each do |rel_path, line_num, name, inp, expected, actual, error_msg| + puts "\n#{rel_path}:#{line_num} #{name}" + puts " Input: #{inp.inspect}" + puts " Expected: #{expected}" + puts " Actual: #{actual}" + puts " Error: #{error_msg}" if error_msg + end + puts "\n... and #{total_failed - max_failures} more failures" if max_failures > 0 && total_failed > max_failures +end + +puts "ruby: #{total_passed} passed, #{total_failed} failed in #{'%.2f' % elapsed}s" +exit(1) if total_failed > 0