diff --git a/.config/nextest.toml b/.config/nextest.toml index 0048196a5..2b4310675 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -49,8 +49,9 @@ filter = 'test(env_set_current_dir)' threads-required = 1 [[profile.default.overrides]] -# Property tests need more time -filter = 'test(/property/)' +# Property tests need more time; a property-test binary's tests count too +# (cli_unify_003_property_tests spawns ruchy 10K times per property) +filter = 'test(/property/) | binary(/property/)' slow-timeout = { period = "90s", terminate-after = 2 } [[profile.coverage.overrides]] diff --git a/.gitignore b/.gitignore index c78dc78fb..a2abb681d 100644 --- a/.gitignore +++ b/.gitignore @@ -136,3 +136,12 @@ dependency_cleanup_analysis.md # PMAT-136: dogfood receipts (written by the ruchy-dogfood skill) .dogfood/ + +# G2B-S2: files pmat writes while tests and hooks run. pmat also drops a `*` +# .gitignore inside each .pmat/ it creates, but that file is untracked and absent +# in a fresh clone; without these rules `cargo package` sees a dirty tree. +src/.pmat/ +.pmat/*.db +.pmat/jidoka.jsonl +.pmat/clippy-receipt +.pmat/dead-code-cache-*.json diff --git a/.pmat/baseline.json b/.pmat/baseline.json index 61648ed1a..71da28304 100644 --- a/.pmat/baseline.json +++ b/.pmat/baseline.json @@ -1,6 +1,6 @@ { "version": "3.41.1", - "created_at": "2026-09-23T14:08:18.278349727Z", + "created_at": "2026-09-24T09:27:51.419412572Z", "git_context": null, "files": { "./build.rs": { @@ -1334,48 +1334,48 @@ }, "./ruchy-embed/src/lib.rs": { "content_hash": [ - 182, + 191, + 153, + 206, + 223, + 218, 172, - 9, - 144, - 64, - 168, - 166, - 252, - 142, - 215, - 221, - 36, - 161, - 204, + 23, 97, - 160, - 119, - 118, - 121, - 78, - 169, - 166, - 237, - 148, - 212, - 22, - 252, - 240, + 25, + 67, + 114, + 162, + 203, + 157, + 5, + 149, 54, - 46, - 174, - 133 + 161, + 3, + 242, + 62, + 176, + 237, + 73, + 151, + 70, + 34, + 93, + 38, + 214, + 74, + 153 ], "score": { "structural_complexity": 25.0, "semantic_complexity": 20.0, - "duplication_ratio": 17.336245, + "duplication_ratio": 17.391304, "coupling_score": 15.0, "doc_coverage": 10.0, "consistency_score": 10.0, "entropy_score": 5.0, - "total": 93.03295, + "total": 93.08301, "grade": "A", "confidence": 1.0, "language": "Rust", @@ -1391,11 +1391,11 @@ }, { "source_metric": "Duplication", - "amount": 2.6637554, + "amount": 2.6086955, "applied_to": [ "Duplication" ], - "issue": "Code duplication: 13.3%" + "issue": "Code duplication: 13.0%" } ], "critical_defects_count": 0, @@ -2432,48 +2432,48 @@ }, "./src/backend/compiler.rs": { "content_hash": [ - 47, - 42, - 157, - 120, - 86, - 178, - 224, - 81, - 231, - 68, - 145, - 202, - 135, - 30, - 169, - 48, - 38, - 112, - 137, - 21, - 147, - 96, - 83, - 203, - 80, - 19, + 251, + 110, 208, - 96, - 128, - 119, - 59, - 90 + 229, + 212, + 140, + 236, + 209, + 210, + 53, + 103, + 199, + 13, + 25, + 21, + 197, + 243, + 150, + 195, + 68, + 225, + 55, + 148, + 67, + 75, + 166, + 68, + 141, + 43, + 205, + 8, + 116 ], "score": { "structural_complexity": 0.0, "semantic_complexity": 20.0, - "duplication_ratio": 16.601307, + "duplication_ratio": 16.494623, "coupling_score": 14.6, "doc_coverage": 10.0, "consistency_score": 10.0, "entropy_score": 5.0, - "total": 76.20131, + "total": 76.09462, "grade": "B", "confidence": 1.0, "language": "Rust", @@ -2485,15 +2485,15 @@ "applied_to": [ "Duplication" ], - "issue": "Found 81 duplicate code patterns" + "issue": "Found 84 duplicate code patterns" }, { "source_metric": "Duplication", - "amount": 3.3986928, + "amount": 3.5053763, "applied_to": [ "Duplication" ], - "issue": "Code duplication: 17.0%" + "issue": "Code duplication: 17.5%" }, { "source_metric": "StructuralComplexity", @@ -3040,49 +3040,49 @@ }, "./src/backend/transpiler/bindings.rs": { "content_hash": [ - 35, - 10, - 211, - 29, - 167, - 63, - 28, - 100, - 228, - 228, - 60, - 122, - 28, - 74, - 67, + 159, + 138, + 50, + 203, + 85, + 188, + 229, + 131, + 127, + 147, + 9, + 181, + 89, + 114, + 227, + 14, + 102, + 81, + 178, + 83, + 105, + 55, + 223, + 137, + 144, + 20, + 205, + 212, + 57, + 118, 215, - 36, - 179, - 157, - 66, - 161, - 226, - 22, - 238, - 170, - 111, - 86, - 167, - 174, - 130, - 58, - 254 + 84 ], "score": { - "structural_complexity": 8.0, + "structural_complexity": 2.5, "semantic_complexity": 20.0, - "duplication_ratio": 13.7076025, + "duplication_ratio": 14.02763, "coupling_score": 15.0, "doc_coverage": 10.0, "consistency_score": 10.0, "entropy_score": 5.0, - "total": 81.7076, - "grade": "B+", + "total": 76.52763, + "grade": "B", "confidence": 1.0, "language": "Rust", "file_path": "./src/backend/transpiler/bindings.rs", @@ -3093,31 +3093,31 @@ "applied_to": [ "Duplication" ], - "issue": "Found 96 duplicate code patterns" + "issue": "Found 97 duplicate code patterns" }, { "source_metric": "Duplication", - "amount": 6.2923975, + "amount": 5.97237, "applied_to": [ "Duplication" ], - "issue": "Code duplication: 31.5%" + "issue": "Code duplication: 29.9%" }, { "source_metric": "StructuralComplexity", - "amount": 9.0, + "amount": 10.0, "applied_to": [ "StructuralComplexity" ], - "issue": "High cognitive complexity: 45" + "issue": "High cognitive complexity: 51" }, { "source_metric": "StructuralComplexity", - "amount": 8.0, + "amount": 12.5, "applied_to": [ "StructuralComplexity" ], - "issue": "High cyclomatic complexity: 46" + "issue": "High cyclomatic complexity: 55" } ], "critical_defects_count": 0, @@ -3135,48 +3135,48 @@ }, "./src/backend/transpiler/block_categorization.rs": { "content_hash": [ - 94, - 142, + 217, + 156, + 213, + 36, + 132, + 213, + 112, + 151, + 215, + 58, + 252, + 197, + 135, + 80, + 24, + 108, 202, - 141, - 32, - 235, - 41, - 230, - 171, - 8, - 59, - 232, - 82, - 173, - 148, - 68, - 237, - 136, - 254, - 104, - 200, - 193, - 78, - 6, - 236, - 238, + 2, + 139, + 229, + 29, + 167, + 168, + 115, + 157, + 21, 81, - 236, - 55, - 227, - 251, - 131 + 16, + 40, + 209, + 92, + 241 ], "score": { "structural_complexity": 0.0, "semantic_complexity": 20.0, - "duplication_ratio": 15.965293, + "duplication_ratio": 15.978378, "coupling_score": 15.0, "doc_coverage": 10.0, "consistency_score": 10.0, "entropy_score": 5.0, - "total": 75.965294, + "total": 75.97838, "grade": "B", "confidence": 1.0, "language": "Rust", @@ -3200,11 +3200,11 @@ }, { "source_metric": "Duplication", - "amount": 4.034707, + "amount": 4.0216217, "applied_to": [ "Duplication" ], - "issue": "Code duplication: 20.2%" + "issue": "Code duplication: 20.1%" }, { "source_metric": "StructuralComplexity", @@ -3238,38 +3238,38 @@ }, "./src/backend/transpiler/block_transpiler.rs": { "content_hash": [ - 251, - 149, - 52, - 146, - 131, - 122, - 31, - 249, - 46, - 124, - 121, - 167, - 172, - 185, - 96, - 35, - 82, - 34, - 185, - 186, - 160, - 192, + 10, + 237, + 81, + 18, + 38, + 128, + 2, 56, - 49, - 199, - 165, - 118, - 254, - 67, - 22, + 74, + 183, + 204, + 110, + 13, + 3, + 252, + 46, + 193, + 226, + 238, + 215, 61, - 4 + 76, + 196, + 181, + 61, + 106, + 10, + 53, + 171, + 70, + 11, + 187 ], "score": { "structural_complexity": 23.8, @@ -3325,48 +3325,48 @@ }, "./src/backend/transpiler/body_generation.rs": { "content_hash": [ - 207, - 74, - 61, + 47, + 146, + 216, + 144, + 100, + 198, + 186, + 146, + 38, + 246, + 106, + 253, + 24, + 80, + 96, + 57, + 128, + 135, + 143, + 203, + 214, + 69, + 62, + 89, + 77, + 129, + 155, 176, - 6, - 196, - 179, - 45, - 111, - 251, - 126, - 254, - 78, - 199, - 94, - 75, - 137, - 142, - 245, - 118, - 109, - 162, - 160, - 109, - 40, - 56, - 152, - 160, - 109, - 16, - 67, - 239 + 119, + 149, + 82, + 185 ], "score": { "structural_complexity": 25.0, "semantic_complexity": 20.0, - "duplication_ratio": 16.356276, + "duplication_ratio": 16.437246, "coupling_score": 15.0, "doc_coverage": 10.0, "consistency_score": 10.0, "entropy_score": 5.0, - "total": 92.14207, + "total": 92.21568, "grade": "A", "confidence": 1.0, "language": "Rust", @@ -3378,15 +3378,15 @@ "applied_to": [ "Duplication" ], - "issue": "Found 27 duplicate code patterns" + "issue": "Found 26 duplicate code patterns" }, { "source_metric": "Duplication", - "amount": 3.6437247, + "amount": 3.562753, "applied_to": [ "Duplication" ], - "issue": "Code duplication: 18.2%" + "issue": "Code duplication: 17.8%" } ], "critical_defects_count": 0, @@ -3491,48 +3491,48 @@ }, "./src/backend/transpiler/call_helpers.rs": { "content_hash": [ - 142, + 18, + 249, + 134, + 121, + 172, + 78, + 54, + 115, 161, - 87, - 65, - 171, - 144, + 93, + 228, 43, - 39, - 126, - 162, - 17, - 185, - 187, - 132, + 57, + 133, + 52, + 244, + 131, + 217, + 74, 156, - 98, - 47, - 116, - 253, - 101, - 27, - 227, - 191, - 125, - 106, - 123, - 189, - 102, - 22, - 164, - 61, - 255 + 255, + 157, + 2, + 230, + 179, + 226, + 142, + 117, + 246, + 135, + 33, + 88 ], "score": { "structural_complexity": 22.0, "semantic_complexity": 20.0, - "duplication_ratio": 14.023668, + "duplication_ratio": 14.127907, "coupling_score": 15.0, "doc_coverage": 10.0, "consistency_score": 10.0, "entropy_score": 5.0, - "total": 96.02367, + "total": 96.12791, "grade": "A+", "confidence": 1.0, "language": "Rust", @@ -3548,11 +3548,11 @@ }, { "source_metric": "Duplication", - "amount": 5.976331, + "amount": 5.8720927, "applied_to": [ "Duplication" ], - "issue": "Code duplication: 29.9%" + "issue": "Code duplication: 29.4%" }, { "source_metric": "StructuralComplexity", @@ -3578,48 +3578,48 @@ }, "./src/backend/transpiler/call_transpilation.rs": { "content_hash": [ + 117, 197, - 159, - 185, - 63, - 204, - 21, - 95, - 14, - 229, - 178, - 11, - 42, - 232, - 65, - 23, - 130, - 204, + 49, + 33, 52, + 58, + 131, + 102, + 3, + 77, 101, - 12, + 83, + 200, + 159, + 224, 81, - 204, - 147, - 10, - 22, - 167, - 110, - 7, - 7, - 219, + 89, + 164, + 76, + 197, + 186, + 0, + 237, + 231, 109, - 146 + 245, + 157, + 8, + 182, + 120, + 127, + 46 ], "score": { "structural_complexity": 0.0, "semantic_complexity": 20.0, - "duplication_ratio": 15.546772, + "duplication_ratio": 16.013905, "coupling_score": 15.0, "doc_coverage": 10.0, "consistency_score": 10.0, "entropy_score": 5.0, - "total": 75.54677, + "total": 76.0139, "grade": "B", "confidence": 1.0, "language": "Rust", @@ -3639,15 +3639,15 @@ "applied_to": [ "Duplication" ], - "issue": "Found 65 duplicate code patterns" + "issue": "Found 70 duplicate code patterns" }, { "source_metric": "Duplication", - "amount": 4.453228, + "amount": 3.986095, "applied_to": [ "Duplication" ], - "issue": "Code duplication: 22.3%" + "issue": "Code duplication: 19.9%" }, { "source_metric": "StructuralComplexity", @@ -3655,15 +3655,15 @@ "applied_to": [ "StructuralComplexity" ], - "issue": "High cognitive complexity: 64" + "issue": "High cognitive complexity: 73" }, { "source_metric": "StructuralComplexity", - "amount": 14.0, + "amount": 15.0, "applied_to": [ "StructuralComplexity" ], - "issue": "High cyclomatic complexity: 58" + "issue": "High cyclomatic complexity: 72" } ], "critical_defects_count": 0, @@ -4464,48 +4464,48 @@ }, "./src/backend/transpiler/dispatcher.rs": { "content_hash": [ - 241, - 191, - 130, - 216, - 228, - 0, - 125, - 81, - 206, - 108, + 24, + 186, + 56, + 230, + 18, + 246, + 217, 68, - 121, - 138, - 16, - 137, - 139, - 243, - 42, - 223, + 133, + 169, + 49, + 47, + 175, + 99, + 239, + 146, + 161, 251, - 229, - 187, - 119, - 66, - 46, - 166, - 113, + 49, + 16, + 202, + 15, + 120, + 145, 200, - 219, - 152, - 182, - 106 + 154, + 74, + 133, + 105, + 106, + 0, + 179 ], "score": { "structural_complexity": 6.1, "semantic_complexity": 20.0, - "duplication_ratio": 12.917809, + "duplication_ratio": 12.927496, "coupling_score": 14.8, "doc_coverage": 10.0, "consistency_score": 10.0, "entropy_score": 5.0, - "total": 78.81781, + "total": 78.8275, "grade": "B", "confidence": 1.0, "language": "Rust", @@ -4521,7 +4521,7 @@ }, { "source_metric": "Duplication", - "amount": 7.0821915, + "amount": 7.0725036, "applied_to": [ "Duplication" ], @@ -4836,48 +4836,48 @@ }, "./src/backend/transpiler/dispatcher_helpers/macro_helpers.rs": { "content_hash": [ - 25, - 4, - 52, + 168, + 101, + 248, + 41, + 41, + 173, + 187, + 46, + 3, + 196, + 165, + 252, + 201, + 114, + 124, + 104, + 247, + 30, + 245, + 40, 222, - 231, - 185, - 94, - 220, - 0, - 205, - 96, - 151, - 14, - 54, + 203, + 2, + 67, + 127, + 13, + 221, + 91, + 238, 74, - 15, - 18, - 187, - 237, - 0, - 240, - 85, - 48, - 192, - 179, - 116, - 174, - 147, - 14, - 147, - 19, - 16 + 236, + 57 ], "score": { "structural_complexity": 24.1, "semantic_complexity": 20.0, - "duplication_ratio": 11.416838, + "duplication_ratio": 11.434426, "coupling_score": 15.0, "doc_coverage": 10.0, "consistency_score": 10.0, "entropy_score": 5.0, - "total": 95.51684, + "total": 95.534424, "grade": "A+", "confidence": 1.0, "language": "Rust", @@ -4893,11 +4893,11 @@ }, { "source_metric": "Duplication", - "amount": 8.583162, + "amount": 8.565574, "applied_to": [ "Duplication" ], - "issue": "Code duplication: 42.9%" + "issue": "Code duplication: 42.8%" }, { "source_metric": "StructuralComplexity", @@ -4923,48 +4923,48 @@ }, "./src/backend/transpiler/dispatcher_helpers/misc.rs": { "content_hash": [ - 203, - 28, - 158, - 32, - 181, - 92, - 220, - 201, - 243, - 106, - 159, - 211, - 166, - 47, - 102, - 57, - 133, - 247, - 187, - 111, - 74, - 176, - 226, - 55, - 211, - 102, - 225, - 55, - 143, - 190, + 208, + 120, + 52, + 107, + 116, + 255, + 179, + 104, + 151, + 43, + 77, + 44, + 193, + 213, + 81, + 150, + 43, + 245, + 255, + 117, + 138, + 109, + 254, 122, - 157 + 185, + 150, + 255, + 247, + 15, + 88, + 42, + 160 ], "score": { "structural_complexity": 25.0, "semantic_complexity": 20.0, - "duplication_ratio": 11.2483225, + "duplication_ratio": 11.375662, "coupling_score": 15.0, "doc_coverage": 10.0, "consistency_score": 10.0, "entropy_score": 5.0, - "total": 96.24832, + "total": 96.37566, "grade": "A+", "confidence": 1.0, "language": "Rust", @@ -4980,11 +4980,11 @@ }, { "source_metric": "Duplication", - "amount": 8.7516775, + "amount": 8.624338, "applied_to": [ "Duplication" ], - "issue": "Code duplication: 43.8%" + "issue": "Code duplication: 43.1%" } ], "critical_defects_count": 0, @@ -5143,48 +5143,48 @@ }, "./src/backend/transpiler/expr_dispatcher.rs": { "content_hash": [ - 214, - 14, - 169, - 91, - 253, - 30, + 61, + 94, + 193, + 163, + 6, + 58, + 93, 181, - 43, - 39, - 158, - 149, - 208, - 64, - 141, - 102, - 233, - 32, - 107, - 63, - 9, - 25, - 78, - 24, - 89, - 11, - 82, - 108, - 182, - 4, - 253, - 241, - 1 + 111, + 156, + 133, + 177, + 211, + 1, + 240, + 100, + 119, + 251, + 237, + 91, + 96, + 196, + 20, + 220, + 234, + 66, + 169, + 56, + 168, + 116, + 242, + 72 ], "score": { "structural_complexity": 25.0, "semantic_complexity": 20.0, - "duplication_ratio": 17.828571, + "duplication_ratio": 17.877094, "coupling_score": 15.0, "doc_coverage": 10.0, "consistency_score": 10.0, "entropy_score": 5.0, - "total": 93.480515, + "total": 93.52463, "grade": "A", "confidence": 1.0, "language": "Rust", @@ -5200,11 +5200,11 @@ }, { "source_metric": "Duplication", - "amount": 2.1714284, + "amount": 2.122905, "applied_to": [ "Duplication" ], - "issue": "Code duplication: 10.9%" + "issue": "Code duplication: 10.6%" } ], "critical_defects_count": 0, @@ -5317,48 +5317,48 @@ }, "./src/backend/transpiler/expressions.rs": { "content_hash": [ - 88, - 174, - 107, - 225, - 59, - 220, - 250, - 19, - 170, - 7, - 136, - 103, - 158, - 53, - 102, + 21, 152, - 76, - 243, - 34, - 47, - 12, - 39, - 184, - 73, - 220, - 222, - 7, - 184, - 237, - 172, - 40, - 150 + 118, + 200, + 239, + 102, + 24, + 150, + 191, + 8, + 180, + 191, + 32, + 232, + 92, + 32, + 201, + 33, + 178, + 186, + 162, + 88, + 69, + 212, + 79, + 153, + 112, + 205, + 179, + 6, + 6, + 87 ], "score": { "structural_complexity": 0.0, "semantic_complexity": 20.0, - "duplication_ratio": 13.133758, + "duplication_ratio": 13.293051, "coupling_score": 15.0, "doc_coverage": 10.0, "consistency_score": 10.0, "entropy_score": 5.0, - "total": 73.13376, + "total": 73.29305, "grade": "B-", "confidence": 1.0, "language": "Rust", @@ -5370,15 +5370,15 @@ "applied_to": [ "Duplication" ], - "issue": "Found 126 duplicate code patterns" + "issue": "Found 131 duplicate code patterns" }, { "source_metric": "Duplication", - "amount": 6.8662424, + "amount": 6.7069488, "applied_to": [ "Duplication" ], - "issue": "Code duplication: 34.3%" + "issue": "Code duplication: 33.5%" }, { "source_metric": "StructuralComplexity", @@ -5386,7 +5386,7 @@ "applied_to": [ "StructuralComplexity" ], - "issue": "High cognitive complexity: 49" + "issue": "High cognitive complexity: 53" }, { "source_metric": "StructuralComplexity", @@ -5394,7 +5394,7 @@ "applied_to": [ "StructuralComplexity" ], - "issue": "High cyclomatic complexity: 112" + "issue": "High cyclomatic complexity: 120" } ], "critical_defects_count": 0, @@ -5491,48 +5491,48 @@ }, "./src/backend/transpiler/expressions_helpers/async_ops.rs": { "content_hash": [ - 139, - 5, - 248, + 202, + 204, + 4, + 143, + 84, + 9, + 31, + 227, + 173, + 165, + 31, + 146, + 29, + 126, + 130, + 67, + 52, + 163, 2, - 65, - 200, - 58, - 177, - 7, - 220, - 228, - 83, - 212, - 38, - 239, - 37, + 243, + 205, + 109, 245, - 60, - 95, - 89, - 19, - 238, - 146, - 142, - 12, - 66, - 172, - 227, - 171, - 244, - 51, - 103 + 189, + 139, + 155, + 110, + 14, + 34, + 152, + 212, + 132 ], "score": { "structural_complexity": 25.0, "semantic_complexity": 20.0, - "duplication_ratio": 14.724409, + "duplication_ratio": 14.806202, "coupling_score": 15.0, "doc_coverage": 10.0, "consistency_score": 10.0, "entropy_score": 5.0, - "total": 99.72441, + "total": 99.8062, "grade": "A+", "confidence": 1.0, "language": "Rust", @@ -5544,15 +5544,15 @@ "applied_to": [ "Duplication" ], - "issue": "Found 21 duplicate code patterns" + "issue": "Found 23 duplicate code patterns" }, { "source_metric": "Duplication", - "amount": 5.2755904, + "amount": 5.1937985, "applied_to": [ "Duplication" ], - "issue": "Code duplication: 26.4%" + "issue": "Code duplication: 26.0%" } ], "critical_defects_count": 0, @@ -5570,48 +5570,48 @@ }, "./src/backend/transpiler/expressions_helpers/binary_ops.rs": { "content_hash": [ - 87, - 7, - 145, - 52, - 194, - 48, - 112, - 236, - 241, - 26, - 133, - 27, - 54, - 186, - 149, - 21, - 122, - 38, - 30, - 234, - 66, - 123, + 239, + 80, 182, - 159, - 208, + 245, + 227, + 243, + 160, + 204, + 196, + 191, + 110, + 213, + 163, + 36, + 119, + 107, + 144, 203, - 129, - 246, - 121, - 85, - 210, - 180 + 130, + 108, + 68, + 33, + 139, + 168, + 141, + 242, + 43, + 158, + 220, + 107, + 244, + 181 ], "score": { "structural_complexity": 4.6, "semantic_complexity": 20.0, - "duplication_ratio": 16.93166, + "duplication_ratio": 16.989246, "coupling_score": 15.0, "doc_coverage": 10.0, "consistency_score": 10.0, "entropy_score": 5.0, - "total": 81.53166, + "total": 81.58925, "grade": "B+", "confidence": 1.0, "language": "Rust", @@ -5623,15 +5623,15 @@ "applied_to": [ "Duplication" ], - "issue": "Found 42 duplicate code patterns" + "issue": "Found 44 duplicate code patterns" }, { "source_metric": "Duplication", - "amount": 3.0683403, + "amount": 3.0107527, "applied_to": [ "Duplication" ], - "issue": "Code duplication: 15.3%" + "issue": "Code duplication: 15.1%" }, { "source_metric": "StructuralComplexity", @@ -5665,48 +5665,48 @@ }, "./src/backend/transpiler/expressions_helpers/collections.rs": { "content_hash": [ - 236, - 209, - 63, - 81, - 30, - 35, - 151, - 150, - 10, - 139, - 175, - 7, - 62, - 137, + 113, + 11, + 143, + 173, + 192, + 59, + 158, + 241, + 143, + 124, + 92, 47, - 231, - 179, - 63, + 215, + 112, + 42, + 112, + 123, + 169, + 189, + 171, + 88, + 11, + 204, + 181, 94, - 132, - 124, - 23, - 248, - 195, + 40, + 100, 179, - 22, - 255, - 49, - 122, - 194, - 198, - 215 + 85, + 189, + 141, + 194 ], "score": { - "structural_complexity": 16.9, + "structural_complexity": 14.599999, "semantic_complexity": 20.0, - "duplication_ratio": 12.539007, + "duplication_ratio": 12.894737, "coupling_score": 15.0, "doc_coverage": 10.0, "consistency_score": 10.0, "entropy_score": 5.0, - "total": 89.43901, + "total": 87.494736, "grade": "A-", "confidence": 1.0, "language": "Rust", @@ -5718,23 +5718,31 @@ "applied_to": [ "Duplication" ], - "issue": "Found 73 duplicate code patterns" + "issue": "Found 77 duplicate code patterns" }, { "source_metric": "Duplication", - "amount": 7.460993, + "amount": 7.1052628, "applied_to": [ "Duplication" ], - "issue": "Code duplication: 37.3%" + "issue": "Code duplication: 35.5%" }, { "source_metric": "StructuralComplexity", - "amount": 8.1, + "amount": 9.900001, "applied_to": [ "StructuralComplexity" ], - "issue": "High cognitive complexity: 42" + "issue": "High cognitive complexity: 48" + }, + { + "source_metric": "StructuralComplexity", + "amount": 0.5, + "applied_to": [ + "StructuralComplexity" + ], + "issue": "High cyclomatic complexity: 31" } ], "critical_defects_count": 0, @@ -5752,48 +5760,48 @@ }, "./src/backend/transpiler/expressions_helpers/field_access.rs": { "content_hash": [ - 35, - 208, - 60, - 158, - 246, - 199, - 116, - 174, - 21, - 205, - 156, - 154, - 122, - 190, - 60, - 226, - 152, - 78, - 182, - 158, - 102, - 184, - 239, - 196, - 3, + 20, + 242, + 249, + 212, + 57, + 120, + 103, + 23, + 201, + 74, + 24, 232, - 4, - 67, - 42, - 171, - 65, - 251 + 151, + 218, + 64, + 5, + 243, + 165, + 221, + 18, + 97, + 209, + 57, + 9, + 78, + 34, + 145, + 201, + 210, + 233, + 228, + 205 ], "score": { - "structural_complexity": 17.1, + "structural_complexity": 16.3, "semantic_complexity": 20.0, - "duplication_ratio": 14.446131, + "duplication_ratio": 14.467456, "coupling_score": 15.0, "doc_coverage": 10.0, "consistency_score": 10.0, "entropy_score": 5.0, - "total": 91.54613, + "total": 90.767456, "grade": "A", "confidence": 1.0, "language": "Rust", @@ -5813,31 +5821,31 @@ "applied_to": [ "Duplication" ], - "issue": "Found 49 duplicate code patterns" + "issue": "Found 52 duplicate code patterns" }, { "source_metric": "Duplication", - "amount": 5.5538697, + "amount": 5.532544, "applied_to": [ "Duplication" ], - "issue": "Code duplication: 27.8%" + "issue": "Code duplication: 27.7%" }, { "source_metric": "StructuralComplexity", - "amount": 5.4, + "amount": 5.7000003, "applied_to": [ "StructuralComplexity" ], - "issue": "High cognitive complexity: 33" + "issue": "High cognitive complexity: 34" }, { "source_metric": "StructuralComplexity", - "amount": 0.5, + "amount": 1.0, "applied_to": [ "StructuralComplexity" ], - "issue": "High cyclomatic complexity: 31" + "issue": "High cyclomatic complexity: 32" } ], "critical_defects_count": 0, @@ -5917,48 +5925,48 @@ }, "./src/backend/transpiler/function_analysis.rs": { "content_hash": [ - 20, - 184, - 174, - 47, - 20, - 28, - 62, - 192, - 9, - 78, - 101, - 100, - 74, - 145, - 25, + 83, + 129, + 225, + 103, 227, - 120, - 75, - 26, - 46, - 229, - 168, - 6, - 181, - 136, - 97, + 252, + 251, + 67, + 94, + 237, + 56, + 90, + 143, + 132, + 84, + 42, + 146, + 195, + 147, + 48, 46, - 68, - 190, - 91, - 217, - 210 + 67, + 233, + 162, + 134, + 219, + 195, + 54, + 72, + 124, + 241, + 118 ], "score": { "structural_complexity": 25.0, "semantic_complexity": 20.0, - "duplication_ratio": 17.246376, + "duplication_ratio": 17.435064, "coupling_score": 15.0, "doc_coverage": 10.0, "consistency_score": 10.0, "entropy_score": 5.0, - "total": 92.95125, + "total": 93.12279, "grade": "A", "confidence": 1.0, "language": "Rust", @@ -5970,15 +5978,15 @@ "applied_to": [ "Duplication" ], - "issue": "Found 42 duplicate code patterns" + "issue": "Found 45 duplicate code patterns" }, { "source_metric": "Duplication", - "amount": 2.753623, + "amount": 2.5649352, "applied_to": [ "Duplication" ], - "issue": "Code duplication: 13.8%" + "issue": "Code duplication: 12.8%" } ], "critical_defects_count": 0, @@ -6091,48 +6099,48 @@ }, "./src/backend/transpiler/function_signature.rs": { "content_hash": [ + 89, + 93, + 184, 216, - 12, + 123, + 99, + 250, + 85, + 77, + 177, 223, - 11, - 159, - 233, - 88, - 246, - 253, - 215, - 33, - 95, - 152, - 73, - 218, - 116, - 72, - 94, - 5, - 169, - 79, - 75, + 248, 147, + 66, 46, - 48, - 174, - 222, - 141, - 19, - 137, - 160, - 213 + 67, + 74, + 132, + 28, + 84, + 208, + 139, + 208, + 188, + 200, + 211, + 164, + 118, + 25, + 121, + 242, + 0 ], "score": { - "structural_complexity": 0.09999943, + "structural_complexity": 0.0, "semantic_complexity": 20.0, - "duplication_ratio": 16.774971, + "duplication_ratio": 16.824644, "coupling_score": 15.0, "doc_coverage": 10.0, "consistency_score": 10.0, "entropy_score": 5.0, - "total": 76.87497, + "total": 76.824646, "grade": "B", "confidence": 1.0, "language": "Rust", @@ -6156,27 +6164,27 @@ }, { "source_metric": "Duplication", - "amount": 3.22503, + "amount": 3.1753554, "applied_to": [ "Duplication" ], - "issue": "Code duplication: 16.1%" + "issue": "Code duplication: 15.9%" }, { "source_metric": "StructuralComplexity", - "amount": 9.900001, + "amount": 10.0, "applied_to": [ "StructuralComplexity" ], - "issue": "High cognitive complexity: 48" + "issue": "High cognitive complexity: 51" }, { "source_metric": "StructuralComplexity", - "amount": 14.0, + "amount": 15.0, "applied_to": [ "StructuralComplexity" ], - "issue": "High cyclomatic complexity: 58" + "issue": "High cyclomatic complexity: 61" } ], "critical_defects_count": 0, @@ -6194,48 +6202,48 @@ }, "./src/backend/transpiler/function_transpiler.rs": { "content_hash": [ - 176, - 103, - 190, - 94, - 78, - 249, - 64, - 137, + 125, + 69, + 120, + 84, + 79, + 33, + 79, + 7, + 34, + 87, + 6, 21, - 247, - 177, - 61, - 254, - 26, - 41, - 179, - 15, - 142, - 147, - 165, - 68, - 57, - 254, - 41, - 42, - 253, - 4, - 78, - 112, - 40, - 40, - 157 + 174, + 92, + 71, + 37, + 101, + 46, + 249, + 80, + 30, + 162, + 13, + 135, + 109, + 119, + 109, + 180, + 152, + 240, + 76, + 52 ], "score": { - "structural_complexity": 19.6, + "structural_complexity": 17.6, "semantic_complexity": 20.0, - "duplication_ratio": 14.026667, + "duplication_ratio": 15.266272, "coupling_score": 15.0, "doc_coverage": 10.0, "consistency_score": 10.0, "entropy_score": 5.0, - "total": 93.62666, + "total": 92.86627, "grade": "A", "confidence": 1.0, "language": "Rust", @@ -6247,23 +6255,31 @@ "applied_to": [ "Duplication" ], - "issue": "Found 22 duplicate code patterns" + "issue": "Found 32 duplicate code patterns" }, { "source_metric": "Duplication", - "amount": 5.9733334, + "amount": 4.733728, "applied_to": [ "Duplication" ], - "issue": "Code duplication: 29.9%" + "issue": "Code duplication: 23.7%" }, { "source_metric": "StructuralComplexity", - "amount": 5.4, + "amount": 6.9, "applied_to": [ "StructuralComplexity" ], - "issue": "High cognitive complexity: 33" + "issue": "High cognitive complexity: 38" + }, + { + "source_metric": "StructuralComplexity", + "amount": 0.5, + "applied_to": [ + "StructuralComplexity" + ], + "issue": "High cyclomatic complexity: 31" } ], "critical_defects_count": 0, @@ -6692,48 +6708,48 @@ }, "./src/backend/transpiler/lifetime_helpers.rs": { "content_hash": [ - 219, - 111, - 68, - 77, - 105, - 164, - 90, - 116, - 60, - 171, - 171, - 32, - 214, - 22, - 154, - 43, - 102, 197, - 114, - 49, - 77, - 254, + 78, + 191, + 144, + 123, + 135, + 71, + 213, + 151, + 17, + 196, + 71, + 29, + 94, 223, - 233, - 5, + 20, + 30, + 31, + 138, + 132, 249, - 225, - 181, - 209, - 116, - 121, - 247 + 218, + 34, + 134, + 38, + 62, + 246, + 7, + 84, + 30, + 216, + 4 ], "score": { - "structural_complexity": 23.8, + "structural_complexity": 23.5, "semantic_complexity": 20.0, - "duplication_ratio": 15.530726, + "duplication_ratio": 15.567867, "coupling_score": 15.0, "doc_coverage": 10.0, "consistency_score": 10.0, "entropy_score": 5.0, - "total": 99.33073, + "total": 99.06787, "grade": "A+", "confidence": 1.0, "language": "Rust", @@ -6749,19 +6765,19 @@ }, { "source_metric": "Duplication", - "amount": 4.4692736, + "amount": 4.4321327, "applied_to": [ "Duplication" ], - "issue": "Code duplication: 22.3%" + "issue": "Code duplication: 22.2%" }, { "source_metric": "StructuralComplexity", - "amount": 1.2, + "amount": 1.5, "applied_to": [ "StructuralComplexity" ], - "issue": "High cognitive complexity: 19" + "issue": "High cognitive complexity: 20" } ], "critical_defects_count": 0, @@ -7212,49 +7228,49 @@ }, "./src/backend/transpiler/mutation_detection.rs": { "content_hash": [ - 155, - 148, - 168, - 89, - 70, - 238, - 241, - 183, - 239, - 22, - 73, - 43, - 158, - 64, - 229, - 149, - 122, - 213, - 37, - 63, - 62, - 117, - 138, 14, - 222, - 62, - 113, - 252, - 23, - 194, - 103, - 68 + 27, + 63, + 179, + 154, + 76, + 90, + 59, + 255, + 32, + 226, + 176, + 43, + 142, + 17, + 251, + 123, + 32, + 163, + 140, + 104, + 226, + 58, + 139, + 209, + 237, + 246, + 52, + 253, + 92, + 28, + 132 ], "score": { - "structural_complexity": 19.3, + "structural_complexity": 8.0, "semantic_complexity": 20.0, - "duplication_ratio": 16.551723, + "duplication_ratio": 17.080868, "coupling_score": 15.0, "doc_coverage": 10.0, "consistency_score": 10.0, "entropy_score": 5.0, - "total": 95.85172, - "grade": "A+", + "total": 85.08087, + "grade": "A-", "confidence": 1.0, "language": "Rust", "file_path": "./src/backend/transpiler/mutation_detection.rs", @@ -7265,23 +7281,31 @@ "applied_to": [ "Duplication" ], - "issue": "Found 49 duplicate code patterns" + "issue": "Found 131 duplicate code patterns" }, { "source_metric": "Duplication", - "amount": 3.448276, + "amount": 2.9191322, "applied_to": [ "Duplication" ], - "issue": "Code duplication: 17.2%" + "issue": "Code duplication: 14.6%" }, { "source_metric": "StructuralComplexity", - "amount": 5.7000003, + "amount": 3.0, "applied_to": [ "StructuralComplexity" ], - "issue": "High cognitive complexity: 34" + "issue": "High cognitive complexity: 25" + }, + { + "source_metric": "StructuralComplexity", + "amount": 14.0, + "applied_to": [ + "StructuralComplexity" + ], + "issue": "High cyclomatic complexity: 58" } ], "critical_defects_count": 0, @@ -7663,49 +7687,49 @@ }, "./src/backend/transpiler/print_helpers.rs": { "content_hash": [ - 254, - 123, - 218, - 98, - 172, - 103, - 149, - 174, - 232, - 96, - 101, - 25, - 111, - 44, + 56, + 99, + 216, 78, - 43, - 199, - 137, - 114, - 106, - 210, - 59, + 192, 199, - 163, - 150, + 181, + 216, + 245, + 225, + 66, + 3, + 109, 96, - 19, - 251, - 141, - 205, - 86, - 85 + 167, + 137, + 208, + 230, + 214, + 87, + 35, + 140, + 15, + 115, + 244, + 121, + 72, + 215, + 64, + 207, + 232, + 64 ], "score": { - "structural_complexity": 20.5, + "structural_complexity": 13.6, "semantic_complexity": 20.0, - "duplication_ratio": 13.72043, + "duplication_ratio": 14.807017, "coupling_score": 15.0, "doc_coverage": 10.0, "consistency_score": 10.0, "entropy_score": 5.0, - "total": 94.22043, - "grade": "A", + "total": 88.40701, + "grade": "A-", "confidence": 1.0, "language": "Rust", "file_path": "./src/backend/transpiler/print_helpers.rs", @@ -7716,15 +7740,23 @@ "applied_to": [ "Duplication" ], - "issue": "Found 45 duplicate code patterns" + "issue": "Found 43 duplicate code patterns" }, { "source_metric": "Duplication", - "amount": 6.2795696, + "amount": 5.1929827, "applied_to": [ "Duplication" ], - "issue": "Code duplication: 31.4%" + "issue": "Code duplication: 26.0%" + }, + { + "source_metric": "StructuralComplexity", + "amount": 6.9, + "applied_to": [ + "StructuralComplexity" + ], + "issue": "High cognitive complexity: 38" }, { "source_metric": "StructuralComplexity", @@ -7732,7 +7764,7 @@ "applied_to": [ "StructuralComplexity" ], - "issue": "High cognitive complexity: 30" + "issue": "High cyclomatic complexity: 39" } ], "critical_defects_count": 0, @@ -7750,48 +7782,48 @@ }, "./src/backend/transpiler/program_transpiler.rs": { "content_hash": [ - 160, - 209, - 255, - 12, - 153, - 198, - 132, - 226, - 142, - 128, + 42, + 2, + 205, + 188, + 0, + 88, + 220, + 162, + 165, + 207, + 141, + 215, + 196, + 149, + 65, + 164, + 68, + 93, + 74, + 137, + 245, 150, - 178, - 67, - 95, - 86, - 186, - 103, - 154, - 1, - 52, - 145, - 103, - 180, - 212, - 151, - 26, - 192, + 64, + 5, + 215, + 249, 68, - 223, - 80, - 38, - 223 + 212, + 208, + 15, + 12, + 2 ], "score": { - "structural_complexity": 9.0, + "structural_complexity": 8.4, "semantic_complexity": 20.0, - "duplication_ratio": 12.835052, + "duplication_ratio": 12.920204, "coupling_score": 15.0, "doc_coverage": 10.0, "consistency_score": 10.0, "entropy_score": 5.0, - "total": 81.83505, + "total": 81.320206, "grade": "B+", "confidence": 1.0, "language": "Rust", @@ -7803,31 +7835,31 @@ "applied_to": [ "Duplication" ], - "issue": "Found 122 duplicate code patterns" + "issue": "Found 124 duplicate code patterns" }, { "source_metric": "Duplication", - "amount": 7.1649485, + "amount": 7.0797963, "applied_to": [ "Duplication" ], - "issue": "Code duplication: 35.8%" + "issue": "Code duplication: 35.4%" }, { "source_metric": "StructuralComplexity", - "amount": 7.5000005, + "amount": 6.6000004, "applied_to": [ "StructuralComplexity" ], - "issue": "High cognitive complexity: 40" + "issue": "High cognitive complexity: 37" }, { "source_metric": "StructuralComplexity", - "amount": 8.5, + "amount": 10.0, "applied_to": [ "StructuralComplexity" ], - "issue": "High cyclomatic complexity: 47" + "issue": "High cyclomatic complexity: 50" } ], "critical_defects_count": 0, @@ -7924,48 +7956,48 @@ }, "./src/backend/transpiler/return_type_helpers.rs": { "content_hash": [ - 145, - 175, - 214, - 40, - 143, + 67, + 253, + 83, + 165, + 67, + 246, + 12, + 197, + 5, + 10, + 21, + 106, + 245, + 110, + 227, + 174, + 162, + 224, + 34, + 60, + 172, + 78, + 252, + 81, + 217, 55, - 61, - 180, - 47, - 69, - 184, - 24, - 53, - 214, - 244, - 88, - 58, - 132, - 119, - 237, - 51, - 26, - 118, - 27, - 8, - 45, - 109, - 29, - 40, 107, - 185, - 31 + 189, + 33, + 223, + 246, + 118 ], "score": { "structural_complexity": 0.0, "semantic_complexity": 20.0, - "duplication_ratio": 17.398375, + "duplication_ratio": 17.588654, "coupling_score": 15.0, "doc_coverage": 10.0, "consistency_score": 10.0, "entropy_score": 5.0, - "total": 77.39838, + "total": 77.58865, "grade": "B", "confidence": 1.0, "language": "Rust", @@ -7977,15 +8009,15 @@ "applied_to": [ "Duplication" ], - "issue": "Found 58 duplicate code patterns" + "issue": "Found 63 duplicate code patterns" }, { "source_metric": "Duplication", - "amount": 2.601626, + "amount": 2.4113474, "applied_to": [ "Duplication" ], - "issue": "Code duplication: 13.0%" + "issue": "Code duplication: 12.1%" }, { "source_metric": "StructuralComplexity", @@ -7993,7 +8025,7 @@ "applied_to": [ "StructuralComplexity" ], - "issue": "High cognitive complexity: 52" + "issue": "High cognitive complexity: 57" }, { "source_metric": "StructuralComplexity", @@ -8001,7 +8033,7 @@ "applied_to": [ "StructuralComplexity" ], - "issue": "High cyclomatic complexity: 63" + "issue": "High cyclomatic complexity: 76" } ], "critical_defects_count": 0, @@ -8019,48 +8051,48 @@ }, "./src/backend/transpiler/statements.rs": { "content_hash": [ - 155, - 27, + 158, + 138, 50, - 207, - 222, - 31, - 223, - 212, - 13, - 166, - 74, - 200, - 33, - 117, - 220, - 94, - 154, - 203, - 38, - 180, - 13, - 44, - 253, - 193, - 204, - 17, - 148, - 38, + 2, + 51, + 208, + 147, + 170, + 90, + 190, + 233, + 155, + 211, + 89, + 230, + 35, + 210, + 63, 15, - 85, - 144, - 149 + 185, + 219, + 121, + 193, + 45, + 106, + 194, + 23, + 244, + 82, + 225, + 91, + 44 ], "score": { "structural_complexity": 25.0, "semantic_complexity": 20.0, - "duplication_ratio": 15.006859, + "duplication_ratio": 15.047619, "coupling_score": 15.0, "doc_coverage": 10.0, "consistency_score": 10.0, "entropy_score": 5.0, - "total": 90.91533, + "total": 90.952385, "grade": "A", "confidence": 1.0, "language": "Rust", @@ -8076,11 +8108,11 @@ }, { "source_metric": "Duplication", - "amount": 4.993141, + "amount": 4.952381, "applied_to": [ "Duplication" ], - "issue": "Code duplication: 25.0%" + "issue": "Code duplication: 24.8%" } ], "critical_defects_count": 0, @@ -8193,48 +8225,48 @@ }, "./src/backend/transpiler/string_body_conversion.rs": { "content_hash": [ - 41, - 197, - 31, + 155, + 17, + 221, + 238, + 172, + 21, + 60, + 3, + 37, + 2, 210, - 70, - 247, - 84, - 216, - 230, - 84, - 225, - 148, - 110, - 87, - 57, - 232, - 217, - 84, - 85, - 44, - 192, + 118, + 46, + 122, + 170, 94, - 61, - 216, - 0, - 207, - 4, - 110, - 81, - 142, - 41, - 90 + 198, + 60, + 86, + 245, + 236, + 202, + 146, + 195, + 160, + 2, + 30, + 25, + 115, + 145, + 143, + 184 ], "score": { "structural_complexity": 24.7, "semantic_complexity": 20.0, - "duplication_ratio": 15.369262, + "duplication_ratio": 15.378486, "coupling_score": 15.0, "doc_coverage": 10.0, "consistency_score": 10.0, "entropy_score": 5.0, - "total": 90.97205, + "total": 90.980446, "grade": "A", "confidence": 1.0, "language": "Rust", @@ -8250,11 +8282,11 @@ }, { "source_metric": "Duplication", - "amount": 4.6307383, + "amount": 4.6215143, "applied_to": [ "Duplication" ], - "issue": "Code duplication: 23.2%" + "issue": "Code duplication: 23.1%" }, { "source_metric": "StructuralComplexity", @@ -8802,48 +8834,48 @@ }, "./src/backend/transpiler/types.rs": { "content_hash": [ - 217, - 10, - 232, - 214, - 75, - 78, - 9, - 111, - 254, - 146, - 105, - 170, - 154, - 239, - 103, - 186, - 48, - 30, - 232, - 143, - 68, - 66, - 127, + 79, + 76, 159, - 118, - 243, - 94, - 140, - 8, - 104, - 153, - 248 + 178, + 195, + 195, + 23, + 70, + 251, + 187, + 46, + 152, + 122, + 87, + 14, + 73, + 37, + 223, + 194, + 83, + 116, + 253, + 178, + 241, + 76, + 223, + 154, + 128, + 156, + 155, + 249, + 150 ], "score": { "structural_complexity": 0.0, "semantic_complexity": 20.0, - "duplication_ratio": 15.392405, + "duplication_ratio": 15.711974, "coupling_score": 15.0, "doc_coverage": 10.0, "consistency_score": 10.0, "entropy_score": 5.0, - "total": 75.3924, + "total": 75.711975, "grade": "B", "confidence": 1.0, "language": "Rust", @@ -8863,15 +8895,15 @@ "applied_to": [ "Duplication" ], - "issue": "Found 137 duplicate code patterns" + "issue": "Found 133 duplicate code patterns" }, { "source_metric": "Duplication", - "amount": 4.607595, + "amount": 4.288026, "applied_to": [ "Duplication" ], - "issue": "Code duplication: 23.0%" + "issue": "Code duplication: 21.4%" }, { "source_metric": "StructuralComplexity", @@ -8879,7 +8911,7 @@ "applied_to": [ "StructuralComplexity" ], - "issue": "High cognitive complexity: 157" + "issue": "High cognitive complexity: 152" }, { "source_metric": "StructuralComplexity", @@ -8887,7 +8919,7 @@ "applied_to": [ "StructuralComplexity" ], - "issue": "High cyclomatic complexity: 130" + "issue": "High cyclomatic complexity: 135" } ], "critical_defects_count": 0, @@ -8905,38 +8937,38 @@ }, "./src/backend/transpiler/utility_builtins.rs": { "content_hash": [ - 125, - 165, - 17, - 234, - 11, - 21, - 90, - 84, - 24, - 152, - 188, - 114, - 111, - 53, - 221, - 96, - 146, - 158, - 167, - 215, - 24, - 211, + 37, + 184, + 142, + 47, + 39, + 46, + 32, + 18, + 238, + 201, + 77, + 87, 13, - 193, - 8, - 245, - 188, - 108, + 173, + 205, + 33, + 210, 174, - 247, - 92, - 236 + 28, + 240, + 161, + 239, + 83, + 93, + 176, + 235, + 82, + 103, + 35, + 185, + 185, + 10 ], "score": { "structural_complexity": 23.8, @@ -9711,38 +9743,38 @@ }, "./src/bench/mod.rs": { "content_hash": [ - 105, - 149, - 47, - 133, - 138, - 84, - 106, - 214, - 115, - 148, - 164, - 65, - 87, - 124, - 101, - 198, - 165, - 17, - 147, + 192, + 56, + 75, + 33, + 13, + 125, + 0, + 241, 121, - 163, - 51, - 52, - 203, - 94, - 123, - 134, - 1, - 106, - 143, + 241, + 63, + 132, + 190, + 154, 62, - 174 + 102, + 203, + 169, + 126, + 149, + 250, + 77, + 12, + 213, + 224, + 77, + 64, + 145, + 112, + 68, + 101, + 45 ], "score": { "structural_complexity": 25.0, @@ -10454,48 +10486,48 @@ }, "./src/bin/handlers/command_router.rs": { "content_hash": [ - 188, - 7, - 223, - 224, - 33, - 164, - 2, - 91, - 61, - 174, - 99, - 138, - 253, - 4, - 18, - 96, - 62, - 69, - 79, - 64, - 198, - 240, - 75, - 239, - 52, - 57, - 245, - 219, - 209, - 98, + 236, + 103, + 241, + 104, + 236, + 159, + 80, + 199, + 187, + 0, + 135, + 148, + 39, + 175, + 162, + 186, + 53, + 155, + 213, + 81, + 53, 154, - 153 + 249, + 149, + 28, + 162, + 196, + 204, + 163, + 253, + 223, + 180 ], "score": { "structural_complexity": 8.2, "semantic_complexity": 20.0, - "duplication_ratio": 17.793493, + "duplication_ratio": 17.797695, "coupling_score": 15.0, "doc_coverage": 10.0, "consistency_score": 10.0, "entropy_score": 5.0, - "total": 85.99349, + "total": 85.997696, "grade": "A-", "confidence": 1.0, "language": "Rust", @@ -10507,11 +10539,11 @@ "applied_to": [ "Duplication" ], - "issue": "Found 121 duplicate code patterns" + "issue": "Found 130 duplicate code patterns" }, { "source_metric": "Duplication", - "amount": 2.2065065, + "amount": 2.2023048, "applied_to": [ "Duplication" ], @@ -10531,7 +10563,7 @@ "applied_to": [ "StructuralComplexity" ], - "issue": "High cyclomatic complexity: 66" + "issue": "High cyclomatic complexity: 67" } ], "critical_defects_count": 0, @@ -11727,49 +11759,49 @@ }, "./src/bin/handlers/handlers_modules/sovereign.rs": { "content_hash": [ - 112, - 104, - 249, - 213, - 99, - 193, - 94, - 172, - 199, - 27, - 118, + 244, + 107, + 68, + 82, + 187, + 169, + 253, + 106, + 175, + 41, + 96, + 42, + 49, + 46, + 11, + 187, + 91, 25, - 131, - 75, - 71, - 178, - 199, - 134, - 59, - 57, - 93, - 190, + 47, + 160, + 225, + 41, + 189, + 91, + 61, + 161, + 63, + 193, 84, - 212, - 50, - 235, - 85, - 48, - 3, - 133, + 206, 70, - 42 + 86 ], "score": { - "structural_complexity": 3.0, + "structural_complexity": 2.5, "semantic_complexity": 20.0, - "duplication_ratio": 17.069923, + "duplication_ratio": 17.13355, "coupling_score": 15.0, "doc_coverage": 10.0, "consistency_score": 10.0, "entropy_score": 5.0, - "total": 80.06992, - "grade": "B+", + "total": 79.63355, + "grade": "B", "confidence": 1.0, "language": "Rust", "file_path": "./src/bin/handlers/handlers_modules/sovereign.rs", @@ -11784,11 +11816,11 @@ }, { "source_metric": "Duplication", - "amount": 2.9300776, + "amount": 2.8664496, "applied_to": [ "Duplication" ], - "issue": "Code duplication: 14.7%" + "issue": "Code duplication: 14.3%" }, { "source_metric": "StructuralComplexity", @@ -11796,15 +11828,15 @@ "applied_to": [ "StructuralComplexity" ], - "issue": "High cognitive complexity: 62" + "issue": "High cognitive complexity: 63" }, { "source_metric": "StructuralComplexity", - "amount": 12.0, + "amount": 12.5, "applied_to": [ "StructuralComplexity" ], - "issue": "High cyclomatic complexity: 54" + "issue": "High cyclomatic complexity: 55" } ], "critical_defects_count": 0, @@ -11901,48 +11933,48 @@ }, "./src/bin/handlers/mcp_handler.rs": { "content_hash": [ - 204, - 28, - 67, - 219, - 46, - 151, - 221, - 126, - 78, - 198, - 241, - 92, - 37, - 140, + 35, + 53, + 27, + 13, + 127, + 235, + 244, 172, - 86, - 122, - 229, - 38, - 241, - 31, + 5, 60, - 179, - 143, - 100, - 237, - 124, + 206, + 134, + 18, + 190, + 185, + 27, + 175, + 141, + 183, + 82, + 145, + 63, + 85, + 153, + 45, + 111, + 154, + 17, + 32, 140, - 214, - 61, - 177, - 91 + 150, + 245 ], "score": { "structural_complexity": 25.0, "semantic_complexity": 19.0, - "duplication_ratio": 20.0, + "duplication_ratio": 16.774193, "coupling_score": 15.0, "doc_coverage": 10.0, "consistency_score": 10.0, "entropy_score": 5.0, - "total": 94.545456, + "total": 91.61291, "grade": "A", "confidence": 1.0, "language": "Rust", @@ -11954,7 +11986,15 @@ "applied_to": [ "Duplication" ], - "issue": "Found 19 duplicate code patterns" + "issue": "Found 14 duplicate code patterns" + }, + { + "source_metric": "Duplication", + "amount": 3.2258062, + "applied_to": [ + "Duplication" + ], + "issue": "Code duplication: 16.1%" }, { "source_metric": "SemanticComplexity", @@ -12897,38 +12937,38 @@ }, "./src/bin/handlers/rhl_handler.rs": { "content_hash": [ - 31, - 177, - 224, - 170, - 148, - 1, - 43, - 252, - 160, + 44, + 34, + 166, + 122, + 81, 28, - 87, - 217, - 220, - 140, - 144, - 219, - 2, - 183, - 250, - 141, - 82, + 213, + 251, + 166, + 202, 50, - 71, - 188, - 124, - 235, - 45, - 159, - 105, - 58, + 182, + 158, + 35, + 239, + 34, + 16, 111, - 83 + 113, + 95, + 179, + 145, + 7, + 189, + 38, + 82, + 116, + 232, + 174, + 168, + 10, + 140 ], "score": { "structural_complexity": 25.0, @@ -12937,8 +12977,8 @@ "coupling_score": 15.0, "doc_coverage": 10.0, "consistency_score": 10.0, - "entropy_score": 8.5, - "total": 98.63637, + "entropy_score": 7.0, + "total": 97.27273, "grade": "A+", "confidence": 1.0, "language": "Rust", @@ -12946,11 +12986,11 @@ "penalties_applied": [ { "source_metric": "Duplication", - "amount": 1.5, + "amount": 3.0, "applied_to": [ "Duplication" ], - "issue": "Found 3 duplicate code patterns" + "issue": "Found 6 duplicate code patterns" } ], "critical_defects_count": 0, @@ -13047,48 +13087,48 @@ }, "./src/bin/handlers/serve_handler.rs": { "content_hash": [ - 59, - 166, - 176, - 38, - 125, - 73, - 174, - 173, + 130, + 153, + 243, + 118, + 143, 132, - 25, - 135, - 55, - 200, - 136, - 205, - 241, + 70, + 138, + 217, 111, - 23, - 235, - 186, - 2, - 44, - 113, + 222, + 106, + 142, + 137, + 153, + 83, + 84, 81, - 226, - 147, - 252, - 44, - 146, - 149, - 73, - 99 + 106, + 208, + 94, + 23, + 62, + 250, + 241, + 209, + 154, + 187, + 7, + 35, + 51, + 123 ], "score": { - "structural_complexity": 19.9, + "structural_complexity": 19.0, "semantic_complexity": 17.5, - "duplication_ratio": 16.007393, + "duplication_ratio": 15.29132, "coupling_score": 15.0, "doc_coverage": 10.0, "consistency_score": 10.0, "entropy_score": 5.0, - "total": 93.407394, + "total": 91.79132, "grade": "A", "confidence": 1.0, "language": "Rust", @@ -13100,23 +13140,23 @@ "applied_to": [ "Duplication" ], - "issue": "Found 70 duplicate code patterns" + "issue": "Found 99 duplicate code patterns" }, { "source_metric": "Duplication", - "amount": 3.9926064, + "amount": 4.70868, "applied_to": [ "Duplication" ], - "issue": "Code duplication: 20.0%" + "issue": "Code duplication: 23.5%" }, { "source_metric": "StructuralComplexity", - "amount": 5.1000004, + "amount": 6.0, "applied_to": [ "StructuralComplexity" ], - "issue": "High cognitive complexity: 32" + "issue": "High cognitive complexity: 35" }, { "source_metric": "SemanticComplexity", @@ -13482,48 +13522,48 @@ }, "./src/bin/ruchy.rs": { "content_hash": [ - 243, - 156, - 49, - 252, - 14, - 254, - 123, - 238, - 150, + 89, + 161, + 63, + 111, + 200, 58, - 192, - 97, - 197, - 64, - 171, - 3, - 91, - 2, - 80, - 178, - 223, - 235, + 172, + 166, + 210, + 141, + 242, + 228, + 51, + 40, + 49, 45, - 74, - 108, - 131, - 27, - 215, - 64, - 35, - 93, - 161 + 120, + 65, + 32, + 37, + 209, + 143, + 214, + 209, + 15, + 147, + 85, + 233, + 21, + 1, + 149, + 239 ], "score": { "structural_complexity": 0.0, "semantic_complexity": 18.0, - "duplication_ratio": 14.036585, + "duplication_ratio": 14.078947, "coupling_score": 15.0, "doc_coverage": 10.0, "consistency_score": 10.0, "entropy_score": 5.0, - "total": 72.03658, + "total": 72.07895, "grade": "B-", "confidence": 1.0, "language": "Rust", @@ -13535,15 +13575,15 @@ "applied_to": [ "Duplication" ], - "issue": "Found 168 duplicate code patterns" + "issue": "Found 173 duplicate code patterns" }, { "source_metric": "Duplication", - "amount": 5.963415, + "amount": 5.921053, "applied_to": [ "Duplication" ], - "issue": "Code duplication: 29.8%" + "issue": "Code duplication: 29.6%" }, { "source_metric": "StructuralComplexity", @@ -13551,7 +13591,7 @@ "applied_to": [ "StructuralComplexity" ], - "issue": "High cognitive complexity: 78" + "issue": "High cognitive complexity: 80" }, { "source_metric": "StructuralComplexity", @@ -13559,7 +13599,7 @@ "applied_to": [ "StructuralComplexity" ], - "issue": "High cyclomatic complexity: 101" + "issue": "High cyclomatic complexity: 106" }, { "source_metric": "SemanticComplexity", @@ -14629,48 +14669,48 @@ }, "./src/frontend/ast.rs": { "content_hash": [ - 76, - 135, - 195, - 247, - 86, - 171, + 113, + 208, + 17, + 152, + 87, + 16, + 131, + 92, + 174, + 59, + 193, + 156, + 56, + 252, + 52, + 220, + 183, + 9, + 240, + 55, + 199, + 203, + 156, + 243, + 169, + 109, + 183, + 219, + 62, + 78, 33, - 130, - 153, - 29, - 244, - 186, - 207, - 108, - 53, - 138, - 223, - 196, - 46, - 127, - 10, - 69, - 147, - 157, - 228, - 229, - 127, - 57, - 96, - 191, - 135, - 243 + 204 ], "score": { "structural_complexity": 12.5, "semantic_complexity": 20.0, - "duplication_ratio": 15.49325, + "duplication_ratio": 15.500516, "coupling_score": 15.0, "doc_coverage": 10.0, "consistency_score": 10.0, "entropy_score": 5.0, - "total": 87.99325, + "total": 88.00052, "grade": "A-", "confidence": 1.0, "language": "Rust", @@ -14686,7 +14726,7 @@ }, { "source_metric": "Duplication", - "amount": 4.5067496, + "amount": 4.499484, "applied_to": [ "Duplication" ], @@ -16045,48 +16085,48 @@ }, "./src/frontend/parser/expressions_helpers/enums.rs": { "content_hash": [ - 71, - 50, - 18, - 214, - 91, - 246, - 138, - 148, - 148, - 253, - 146, - 40, - 73, - 92, - 174, - 107, - 210, - 92, - 230, - 229, - 129, - 129, + 45, + 42, + 216, + 13, + 190, + 13, + 232, + 83, + 137, + 222, + 164, + 207, + 154, + 79, 117, - 145, - 213, - 59, - 115, + 4, + 191, + 61, + 171, + 232, + 44, + 243, + 26, + 228, + 70, + 187, + 184, + 133, + 86, 117, - 181, - 178, - 229, - 61 + 191, + 247 ], "score": { - "structural_complexity": 5.0, + "structural_complexity": 4.5, "semantic_complexity": 20.0, - "duplication_ratio": 17.301981, + "duplication_ratio": 17.339901, "coupling_score": 15.0, "doc_coverage": 10.0, "consistency_score": 10.0, "entropy_score": 5.0, - "total": 82.30198, + "total": 81.839905, "grade": "B+", "confidence": 1.0, "language": "Rust", @@ -16102,11 +16142,11 @@ }, { "source_metric": "Duplication", - "amount": 2.6980197, + "amount": 2.6600983, "applied_to": [ "Duplication" ], - "issue": "Code duplication: 13.5%" + "issue": "Code duplication: 13.3%" }, { "source_metric": "StructuralComplexity", @@ -16118,11 +16158,11 @@ }, { "source_metric": "StructuralComplexity", - "amount": 10.0, + "amount": 10.5, "applied_to": [ "StructuralComplexity" ], - "issue": "High cyclomatic complexity: 50" + "issue": "High cyclomatic complexity: 51" } ], "critical_defects_count": 0, @@ -16771,38 +16811,38 @@ }, "./src/frontend/parser/expressions_helpers/modules.rs": { "content_hash": [ - 214, - 90, - 108, - 125, - 169, - 95, - 18, - 74, - 31, - 98, - 251, - 59, - 230, - 67, - 131, - 52, - 130, - 202, - 29, - 209, - 68, - 64, - 42, - 92, - 119, - 130, - 122, - 28, - 16, - 131, - 61, - 117 + 101, + 155, + 19, + 223, + 83, + 45, + 196, + 242, + 218, + 62, + 104, + 153, + 221, + 227, + 40, + 73, + 192, + 96, + 91, + 49, + 41, + 57, + 135, + 78, + 254, + 44, + 134, + 140, + 106, + 229, + 161, + 253 ], "score": { "structural_complexity": 25.0, @@ -16824,7 +16864,7 @@ "applied_to": [ "Duplication" ], - "issue": "Found 13 duplicate code patterns" + "issue": "Found 12 duplicate code patterns" } ], "critical_defects_count": 0, @@ -16842,48 +16882,48 @@ }, "./src/frontend/parser/expressions_helpers/patterns.rs": { "content_hash": [ - 32, - 91, - 24, - 179, + 54, + 111, + 107, + 78, + 69, + 199, + 40, + 161, + 55, 208, - 164, - 135, - 171, - 89, - 102, - 210, - 186, + 36, + 93, + 111, + 105, + 122, + 43, 68, - 186, - 44, - 108, - 217, - 246, - 228, - 137, - 174, - 119, - 203, - 206, - 223, - 9, + 79, + 43, + 178, 238, - 105, - 242, - 117, - 65, - 127 + 237, + 182, + 215, + 26, + 122, + 136, + 1, + 19, + 153, + 174, + 82 ], "score": { "structural_complexity": 0.0, "semantic_complexity": 19.0, - "duplication_ratio": 16.032389, + "duplication_ratio": 16.0364, "coupling_score": 15.0, "doc_coverage": 10.0, "consistency_score": 10.0, "entropy_score": 5.0, - "total": 75.03239, + "total": 75.0364, "grade": "B", "confidence": 1.0, "language": "Rust", @@ -16899,7 +16939,7 @@ }, { "source_metric": "Duplication", - "amount": 3.9676113, + "amount": 3.9635994, "applied_to": [ "Duplication" ], @@ -16919,7 +16959,7 @@ "applied_to": [ "StructuralComplexity" ], - "issue": "High cyclomatic complexity: 171" + "issue": "High cyclomatic complexity: 172" }, { "source_metric": "SemanticComplexity", @@ -17032,48 +17072,48 @@ }, "./src/frontend/parser/expressions_helpers/structs.rs": { "content_hash": [ - 234, - 161, - 71, - 34, - 32, - 58, - 155, - 178, - 222, - 1, + 4, + 46, + 59, + 172, + 57, + 93, + 38, + 85, + 68, + 207, + 113, + 187, + 227, + 219, + 133, + 96, + 136, + 47, + 130, 39, - 11, - 151, - 192, - 180, - 15, - 247, - 186, - 44, + 168, 93, - 191, - 95, - 31, - 1, - 232, - 65, - 145, - 14, - 126, - 13, - 29, - 16 + 131, + 212, + 122, + 2, + 131, + 151, + 25, + 72, + 24, + 162 ], "score": { - "structural_complexity": 3.0, + "structural_complexity": 2.5, "semantic_complexity": 20.0, - "duplication_ratio": 16.533638, + "duplication_ratio": 16.572077, "coupling_score": 15.0, "doc_coverage": 10.0, "consistency_score": 10.0, "entropy_score": 5.0, - "total": 79.53364, + "total": 79.072075, "grade": "B", "confidence": 1.0, "language": "Rust", @@ -17085,15 +17125,15 @@ "applied_to": [ "Duplication" ], - "issue": "Found 47 duplicate code patterns" + "issue": "Found 46 duplicate code patterns" }, { "source_metric": "Duplication", - "amount": 3.4663627, + "amount": 3.4279227, "applied_to": [ "Duplication" ], - "issue": "Code duplication: 17.3%" + "issue": "Code duplication: 17.1%" }, { "source_metric": "StructuralComplexity", @@ -17105,11 +17145,11 @@ }, { "source_metric": "StructuralComplexity", - "amount": 12.0, + "amount": 12.5, "applied_to": [ "StructuralComplexity" ], - "issue": "High cyclomatic complexity: 54" + "issue": "High cyclomatic complexity: 55" } ], "critical_defects_count": 0, @@ -17752,48 +17792,48 @@ }, "./src/frontend/parser/functions.rs": { "content_hash": [ - 154, - 113, - 12, - 169, - 17, - 136, - 7, - 230, - 153, - 174, - 6, - 106, - 189, - 76, - 123, - 146, - 35, - 21, - 64, - 30, - 167, + 115, + 116, + 251, + 31, 224, - 186, - 75, - 247, - 173, - 208, - 6, + 196, + 142, + 62, + 65, + 222, + 189, + 246, + 100, + 54, 102, - 154, - 123, - 17 + 12, + 145, + 241, + 130, + 104, + 223, + 249, + 65, + 6, + 58, + 115, + 109, + 246, + 16, + 26, + 126, + 86 ], "score": { "structural_complexity": 0.0, "semantic_complexity": 20.0, - "duplication_ratio": 15.78125, + "duplication_ratio": 15.962644, "coupling_score": 15.0, "doc_coverage": 10.0, "consistency_score": 10.0, "entropy_score": 5.0, - "total": 75.78125, + "total": 75.96265, "grade": "B", "confidence": 1.0, "language": "Rust", @@ -17805,15 +17845,15 @@ "applied_to": [ "Duplication" ], - "issue": "Found 105 duplicate code patterns" + "issue": "Found 102 duplicate code patterns" }, { "source_metric": "Duplication", - "amount": 4.21875, + "amount": 4.0373564, "applied_to": [ "Duplication" ], - "issue": "Code duplication: 21.1%" + "issue": "Code duplication: 20.2%" }, { "source_metric": "StructuralComplexity", @@ -17821,7 +17861,7 @@ "applied_to": [ "StructuralComplexity" ], - "issue": "High cognitive complexity: 82" + "issue": "High cognitive complexity: 84" }, { "source_metric": "StructuralComplexity", @@ -17829,7 +17869,7 @@ "applied_to": [ "StructuralComplexity" ], - "issue": "High cyclomatic complexity: 85" + "issue": "High cyclomatic complexity: 81" } ], "critical_defects_count": 0, @@ -18114,50 +18154,121 @@ }, "git_context": null }, - "./src/frontend/parser/mod.rs": { + "./src/frontend/parser/member_names.rs": { "content_hash": [ - 247, - 213, - 22, - 110, - 214, - 62, - 87, - 16, - 46, - 183, + 255, + 59, + 230, + 41, + 92, + 168, + 24, + 189, + 199, + 70, + 26, + 34, + 126, + 48, + 31, + 190, + 45, + 124, + 135, + 131, 244, - 147, - 183, - 65, + 54, + 4, 240, - 241, - 6, - 13, - 86, - 106, - 247, - 124, - 231, + 87, + 32, 76, - 41, - 125, - 33, - 17, - 3, - 148, + 249, + 118, + 104, + 174, + 84 + ], + "score": { + "structural_complexity": 25.0, + "semantic_complexity": 20.0, + "duplication_ratio": 20.0, + "coupling_score": 15.0, + "doc_coverage": 10.0, + "consistency_score": 10.0, + "entropy_score": 8.0, + "total": 98.18182, + "grade": "A+", + "confidence": 1.0, + "language": "Rust", + "file_path": "./src/frontend/parser/member_names.rs", + "penalties_applied": [ + { + "source_metric": "Duplication", + "amount": 2.0, + "applied_to": [ + "Duplication" + ], + "issue": "Found 4 duplicate code patterns" + } + ], + "critical_defects_count": 0, + "has_critical_defects": false, + "has_contract_coverage": false + }, + "components": { + "complexity_breakdown": {}, + "duplication_sources": [], + "coupling_dependencies": [], + "doc_missing_items": [], + "consistency_violations": [] + }, + "git_context": null + }, + "./src/frontend/parser/mod.rs": { + "content_hash": [ + 201, + 182, + 251, + 234, + 120, + 197, + 152, + 66, + 58, + 126, 226, - 100 + 108, + 153, + 21, + 215, + 176, + 232, + 14, + 244, + 159, + 158, + 93, + 86, + 119, + 122, + 247, + 198, + 223, + 199, + 230, + 191, + 130 ], "score": { "structural_complexity": 0.0, "semantic_complexity": 20.0, - "duplication_ratio": 17.003122, + "duplication_ratio": 17.045454, "coupling_score": 15.0, "doc_coverage": 10.0, "consistency_score": 10.0, "entropy_score": 5.0, - "total": 77.00312, + "total": 77.045456, "grade": "B", "confidence": 1.0, "language": "Rust", @@ -18169,15 +18280,15 @@ "applied_to": [ "Duplication" ], - "issue": "Found 68 duplicate code patterns" + "issue": "Found 67 duplicate code patterns" }, { "source_metric": "Duplication", - "amount": 2.9968784, + "amount": 2.9545453, "applied_to": [ "Duplication" ], - "issue": "Code duplication: 15.0%" + "issue": "Code duplication: 14.8%" }, { "source_metric": "StructuralComplexity", @@ -18282,48 +18393,48 @@ }, "./src/frontend/parser/types.rs": { "content_hash": [ - 58, - 253, - 21, - 253, - 43, - 190, - 239, - 222, - 218, - 179, - 238, - 149, - 53, - 196, - 16, - 57, - 42, - 104, - 194, - 216, + 241, + 113, + 1, + 31, + 221, + 129, + 160, 180, + 85, + 228, + 190, 103, - 96, - 129, - 195, - 112, - 65, - 159, - 70, - 159, - 140, - 180 + 18, + 60, + 69, + 58, + 106, + 143, + 89, + 124, + 46, + 100, + 165, + 120, + 32, + 167, + 97, + 108, + 193, + 218, + 218, + 154 ], "score": { "structural_complexity": 24.7, "semantic_complexity": 20.0, - "duplication_ratio": 16.02649, + "duplication_ratio": 16.129032, "coupling_score": 15.0, "doc_coverage": 10.0, "consistency_score": 10.0, "entropy_score": 5.0, - "total": 91.569534, + "total": 91.66276, "grade": "A", "confidence": 1.0, "language": "Rust", @@ -18339,11 +18450,11 @@ }, { "source_metric": "Duplication", - "amount": 3.97351, + "amount": 3.8709676, "applied_to": [ "Duplication" ], - "issue": "Code duplication: 19.9%" + "issue": "Code duplication: 19.4%" }, { "source_metric": "StructuralComplexity", @@ -18882,38 +18993,38 @@ }, "./src/frontend/parser/utils_helpers/string_interpolation.rs": { "content_hash": [ - 197, - 187, + 177, + 26, + 145, + 48, + 114, + 26, 167, - 59, - 80, - 152, - 98, - 139, + 191, + 6, + 170, + 83, + 9, 19, - 150, - 177, - 233, - 108, - 205, - 21, - 56, - 198, - 56, - 130, - 41, - 94, - 97, - 158, - 195, - 146, - 80, - 69, + 115, + 73, + 102, 159, - 142, - 240, - 169, - 165 + 127, + 28, + 69, + 112, + 38, + 231, + 202, + 203, + 71, + 251, + 139, + 212, + 17, + 100, + 55 ], "score": { "structural_complexity": 20.3, @@ -20822,6 +20933,77 @@ }, "git_context": null }, + "./src/mcp/rhl_tools.rs": { + "content_hash": [ + 62, + 74, + 163, + 104, + 209, + 199, + 229, + 216, + 81, + 169, + 60, + 27, + 102, + 83, + 77, + 58, + 105, + 60, + 208, + 63, + 192, + 253, + 245, + 181, + 98, + 36, + 247, + 27, + 53, + 201, + 63, + 132 + ], + "score": { + "structural_complexity": 25.0, + "semantic_complexity": 20.0, + "duplication_ratio": 20.0, + "coupling_score": 15.0, + "doc_coverage": 10.0, + "consistency_score": 10.0, + "entropy_score": 5.0, + "total": 95.454544, + "grade": "A+", + "confidence": 1.0, + "language": "Rust", + "file_path": "./src/mcp/rhl_tools.rs", + "penalties_applied": [ + { + "source_metric": "Duplication", + "amount": 5.0, + "applied_to": [ + "Duplication" + ], + "issue": "Found 11 duplicate code patterns" + } + ], + "critical_defects_count": 0, + "has_critical_defects": false, + "has_contract_coverage": false + }, + "components": { + "complexity_breakdown": {}, + "duplication_sources": [], + "coupling_dependencies": [], + "doc_missing_items": [], + "consistency_violations": [] + }, + "git_context": null + }, "./src/mcp/tool_discovery.rs": { "content_hash": [ 250, @@ -20911,38 +21093,38 @@ }, "./src/mcp.rs": { "content_hash": [ - 217, - 224, - 220, - 85, - 184, - 30, - 184, - 4, - 49, - 82, - 59, - 199, - 200, - 249, - 190, - 79, - 96, - 156, - 253, - 138, - 68, - 107, - 90, - 11, + 135, + 215, + 251, + 50, + 22, + 194, 19, - 253, - 137, - 3, - 169, - 75, - 151, - 43 + 5, + 131, + 166, + 84, + 66, + 47, + 140, + 46, + 172, + 154, + 66, + 86, + 252, + 150, + 24, + 179, + 78, + 49, + 36, + 251, + 217, + 103, + 78, + 191, + 119 ], "score": { "structural_complexity": 25.0, @@ -20964,7 +21146,7 @@ "applied_to": [ "Duplication" ], - "issue": "Found 36 duplicate code patterns" + "issue": "Found 34 duplicate code patterns" } ], "critical_defects_count": 0, @@ -27570,48 +27752,48 @@ }, "./src/quality/formatter.rs": { "content_hash": [ - 231, - 232, - 57, - 132, - 209, - 129, - 194, - 167, - 174, - 248, - 162, - 6, - 109, - 212, - 17, - 251, - 237, - 110, - 213, - 176, - 151, - 59, - 147, - 82, - 93, - 38, - 190, - 58, - 199, - 53, - 185, - 51 + 54, + 163, + 225, + 76, + 25, + 184, + 84, + 255, + 81, + 88, + 30, + 203, + 56, + 230, + 78, + 203, + 198, + 192, + 159, + 206, + 253, + 10, + 242, + 138, + 208, + 76, + 78, + 172, + 206, + 44, + 119, + 114 ], "score": { "structural_complexity": 0.0, "semantic_complexity": 20.0, - "duplication_ratio": 16.393238, + "duplication_ratio": 16.398754, "coupling_score": 15.0, "doc_coverage": 10.0, "consistency_score": 10.0, "entropy_score": 5.0, - "total": 76.393234, + "total": 76.39876, "grade": "B", "confidence": 1.0, "language": "Rust", @@ -27631,11 +27813,11 @@ "applied_to": [ "Duplication" ], - "issue": "Found 128 duplicate code patterns" + "issue": "Found 129 duplicate code patterns" }, { "source_metric": "Duplication", - "amount": 3.6067626, + "amount": 3.601246, "applied_to": [ "Duplication" ], @@ -27647,7 +27829,7 @@ "applied_to": [ "StructuralComplexity" ], - "issue": "High cognitive complexity: 140" + "issue": "High cognitive complexity: 141" }, { "source_metric": "StructuralComplexity", @@ -27655,7 +27837,7 @@ "applied_to": [ "StructuralComplexity" ], - "issue": "High cyclomatic complexity: 238" + "issue": "High cyclomatic complexity: 240" } ], "critical_defects_count": 0, @@ -27673,38 +27855,38 @@ }, "./src/quality/formatter_config.rs": { "content_hash": [ - 224, - 145, - 198, - 147, - 201, - 131, - 129, - 58, - 33, - 72, - 6, - 105, - 187, - 211, - 56, - 98, - 91, - 93, - 52, - 179, - 42, - 218, + 252, 179, - 106, - 123, + 135, + 66, 2, - 81, - 163, - 210, - 142, - 244, - 95 + 52, + 175, + 172, + 130, + 156, + 160, + 5, + 189, + 83, + 150, + 62, + 149, + 30, + 246, + 127, + 255, + 54, + 121, + 141, + 232, + 214, + 74, + 205, + 104, + 152, + 38, + 38 ], "score": { "structural_complexity": 25.0, @@ -29902,38 +30084,38 @@ }, "./src/rhl/check.rs": { "content_hash": [ - 207, - 62, - 191, + 68, + 228, + 168, + 155, + 232, + 157, + 114, + 168, 87, - 206, - 124, - 52, - 230, - 81, + 233, + 1, + 56, + 46, + 58, + 31, + 178, + 39, + 205, 34, - 147, - 3, - 28, - 242, - 136, - 244, - 138, - 193, - 249, - 24, + 41, + 153, + 170, + 246, + 13, + 33, + 110, + 179, + 209, 196, - 38, - 200, - 253, - 9, - 104, - 69, - 78, - 219, - 35, - 57, - 23 + 155, + 185, + 42 ], "score": { "structural_complexity": 23.2, @@ -29955,7 +30137,7 @@ "applied_to": [ "Duplication" ], - "issue": "Found 10 duplicate code patterns" + "issue": "Found 14 duplicate code patterns" }, { "source_metric": "StructuralComplexity", @@ -29981,49 +30163,49 @@ }, "./src/rhl/cli.rs": { "content_hash": [ - 221, - 154, - 38, - 188, - 220, - 229, + 158, + 144, + 241, + 223, + 92, + 202, + 131, + 169, + 254, + 130, + 238, + 208, 139, - 110, - 31, - 207, - 54, - 220, - 148, - 45, - 51, - 137, - 145, - 48, - 240, - 78, - 150, - 140, - 227, - 120, - 232, - 26, - 166, + 218, + 88, + 118, + 204, + 179, + 253, + 73, 150, - 241, - 194, - 44, - 122 + 76, + 134, + 117, + 86, + 104, + 41, + 230, + 126, + 243, + 28, + 138 ], "score": { - "structural_complexity": 22.0, + "structural_complexity": 0.0, "semantic_complexity": 20.0, "duplication_ratio": 20.0, "coupling_score": 15.0, "doc_coverage": 10.0, "consistency_score": 10.0, "entropy_score": 5.0, - "total": 92.72727, - "grade": "A", + "total": 80.0, + "grade": "B+", "confidence": 1.0, "language": "Rust", "file_path": "./src/rhl/cli.rs", @@ -30034,23 +30216,23 @@ "applied_to": [ "Duplication" ], - "issue": "Found 21 duplicate code patterns" + "issue": "Found 47 duplicate code patterns" }, { "source_metric": "StructuralComplexity", - "amount": 1.5, + "amount": 10.0, "applied_to": [ "StructuralComplexity" ], - "issue": "High cognitive complexity: 20" + "issue": "High cognitive complexity: 50" }, { "source_metric": "StructuralComplexity", - "amount": 1.5, + "amount": 15.0, "applied_to": [ "StructuralComplexity" ], - "issue": "High cyclomatic complexity: 33" + "issue": "High cyclomatic complexity: 81" } ], "critical_defects_count": 0, @@ -30068,38 +30250,38 @@ }, "./src/rhl/codes.rs": { "content_hash": [ + 35, 238, - 251, - 57, - 42, - 212, - 151, - 243, - 88, - 2, - 69, - 155, - 241, - 135, + 184, + 51, + 254, + 25, + 147, 74, - 88, + 218, + 47, + 68, + 186, 163, - 127, - 196, - 191, - 6, + 80, + 141, + 159, + 116, + 48, + 242, + 192, + 143, + 165, + 238, + 96, + 118, 7, - 36, - 33, - 237, - 42, - 218, - 255, - 111, - 107, - 198, + 144, + 164, + 250, 231, - 55 + 8, + 158 ], "score": { "structural_complexity": 25.0, @@ -30108,8 +30290,8 @@ "coupling_score": 15.0, "doc_coverage": 10.0, "consistency_score": 10.0, - "entropy_score": 8.5, - "total": 98.63637, + "entropy_score": 7.5, + "total": 97.72727, "grade": "A+", "confidence": 1.0, "language": "Rust", @@ -30117,11 +30299,11 @@ "penalties_applied": [ { "source_metric": "Duplication", - "amount": 1.5, + "amount": 2.5, "applied_to": [ "Duplication" ], - "issue": "Found 3 duplicate code patterns" + "issue": "Found 5 duplicate code patterns" } ], "critical_defects_count": 0, @@ -30137,54 +30319,54 @@ }, "git_context": null }, - "./src/rhl/diag.rs": { + "./src/rhl/contract.rs": { "content_hash": [ - 244, - 201, - 39, - 151, - 234, - 226, - 34, - 195, - 50, - 234, - 171, - 119, + 158, + 135, + 143, + 79, + 221, + 80, 51, - 117, - 65, - 52, + 214, + 62, + 212, + 215, + 105, + 91, + 243, + 115, + 29, + 17, + 183, + 233, + 246, + 159, + 29, + 97, + 124, + 30, + 123, + 162, + 116, + 179, + 112, 59, - 223, - 102, - 8, - 231, - 64, - 5, - 33, - 73, - 130, - 182, - 93, - 89, - 70, - 223, - 92 + 224 ], "score": { - "structural_complexity": 25.0, + "structural_complexity": 6.1, "semantic_complexity": 20.0, "duplication_ratio": 20.0, "coupling_score": 15.0, "doc_coverage": 10.0, "consistency_score": 10.0, "entropy_score": 5.0, - "total": 95.454544, - "grade": "A+", + "total": 86.1, + "grade": "A-", "confidence": 1.0, "language": "Rust", - "file_path": "./src/rhl/diag.rs", + "file_path": "./src/rhl/contract.rs", "penalties_applied": [ { "source_metric": "Duplication", @@ -30192,7 +30374,23 @@ "applied_to": [ "Duplication" ], - "issue": "Found 23 duplicate code patterns" + "issue": "Found 17 duplicate code patterns" + }, + { + "source_metric": "StructuralComplexity", + "amount": 3.9, + "applied_to": [ + "StructuralComplexity" + ], + "issue": "High cognitive complexity: 28" + }, + { + "source_metric": "StructuralComplexity", + "amount": 15.0, + "applied_to": [ + "StructuralComplexity" + ], + "issue": "High cyclomatic complexity: 73" } ], "critical_defects_count": 0, @@ -30208,35 +30406,351 @@ }, "git_context": null }, - "./src/rhl/fix.rs": { + "./src/rhl/contract_render.rs": { "content_hash": [ - 217, - 36, - 90, - 193, - 144, - 25, - 91, - 48, - 160, - 178, - 71, - 17, - 249, - 235, - 21, + 179, + 1, + 7, + 12, + 3, + 171, + 37, + 125, + 142, + 171, + 242, + 39, + 50, + 37, + 254, 249, - 162, - 26, - 87, - 199, - 168, - 212, - 27, - 154, - 38, - 140, - 168, + 202, + 124, + 161, + 95, + 131, + 229, + 207, + 36, + 113, + 221, + 106, + 226, + 147, + 181, + 69, + 56 + ], + "score": { + "structural_complexity": 25.0, + "semantic_complexity": 20.0, + "duplication_ratio": 20.0, + "coupling_score": 15.0, + "doc_coverage": 10.0, + "consistency_score": 10.0, + "entropy_score": 5.0, + "total": 95.454544, + "grade": "A+", + "confidence": 1.0, + "language": "Rust", + "file_path": "./src/rhl/contract_render.rs", + "penalties_applied": [ + { + "source_metric": "Duplication", + "amount": 5.0, + "applied_to": [ + "Duplication" + ], + "issue": "Found 14 duplicate code patterns" + } + ], + "critical_defects_count": 0, + "has_critical_defects": false, + "has_contract_coverage": false + }, + "components": { + "complexity_breakdown": {}, + "duplication_sources": [], + "coupling_dependencies": [], + "doc_missing_items": [], + "consistency_violations": [] + }, + "git_context": null + }, + "./src/rhl/diag.rs": { + "content_hash": [ + 244, + 201, + 39, + 151, + 234, + 226, + 34, + 195, + 50, + 234, + 171, + 119, + 51, + 117, + 65, + 52, + 59, + 223, + 102, + 8, + 231, + 64, + 5, + 33, + 73, + 130, + 182, + 93, + 89, + 70, + 223, + 92 + ], + "score": { + "structural_complexity": 25.0, + "semantic_complexity": 20.0, + "duplication_ratio": 20.0, + "coupling_score": 15.0, + "doc_coverage": 10.0, + "consistency_score": 10.0, + "entropy_score": 5.0, + "total": 95.454544, + "grade": "A+", + "confidence": 1.0, + "language": "Rust", + "file_path": "./src/rhl/diag.rs", + "penalties_applied": [ + { + "source_metric": "Duplication", + "amount": 5.0, + "applied_to": [ + "Duplication" + ], + "issue": "Found 23 duplicate code patterns" + } + ], + "critical_defects_count": 0, + "has_critical_defects": false, + "has_contract_coverage": false + }, + "components": { + "complexity_breakdown": {}, + "duplication_sources": [], + "coupling_dependencies": [], + "doc_missing_items": [], + "consistency_violations": [] + }, + "git_context": null + }, + "./src/rhl/examples.rs": { + "content_hash": [ + 73, + 69, + 222, + 222, + 221, + 232, + 105, + 129, + 217, + 154, + 126, + 150, + 228, + 130, + 244, + 209, + 166, + 96, + 75, + 86, + 57, + 174, + 137, + 196, + 74, + 138, + 106, + 132, + 41, + 23, + 212, + 157 + ], + "score": { + "structural_complexity": 12.0, + "semantic_complexity": 20.0, + "duplication_ratio": 20.0, + "coupling_score": 15.0, + "doc_coverage": 10.0, + "consistency_score": 10.0, + "entropy_score": 5.0, + "total": 92.0, + "grade": "A", + "confidence": 1.0, + "language": "Rust", + "file_path": "./src/rhl/examples.rs", + "penalties_applied": [ + { + "source_metric": "Duplication", + "amount": 5.0, + "applied_to": [ + "Duplication" + ], + "issue": "Found 27 duplicate code patterns" + }, + { + "source_metric": "StructuralComplexity", + "amount": 6.0, + "applied_to": [ + "StructuralComplexity" + ], + "issue": "High cognitive complexity: 35" + }, + { + "source_metric": "StructuralComplexity", + "amount": 7.0, + "applied_to": [ + "StructuralComplexity" + ], + "issue": "High cyclomatic complexity: 44" + } + ], + "critical_defects_count": 0, + "has_critical_defects": false, + "has_contract_coverage": false + }, + "components": { + "complexity_breakdown": {}, + "duplication_sources": [], + "coupling_dependencies": [], + "doc_missing_items": [], + "consistency_violations": [] + }, + "git_context": null + }, + "./src/rhl/explain.rs": { + "content_hash": [ + 221, + 48, + 84, + 65, + 152, + 112, + 60, + 137, + 143, + 205, + 1, + 10, + 7, + 66, + 108, + 56, + 24, + 120, + 232, + 51, + 166, + 100, + 157, + 140, + 134, + 251, + 193, + 240, + 103, + 166, + 110, + 186 + ], + "score": { + "structural_complexity": 13.0, + "semantic_complexity": 20.0, + "duplication_ratio": 20.0, + "coupling_score": 15.0, + "doc_coverage": 10.0, + "consistency_score": 10.0, + "entropy_score": 5.0, + "total": 93.0, + "grade": "A", + "confidence": 1.0, + "language": "Rust", + "file_path": "./src/rhl/explain.rs", + "penalties_applied": [ + { + "source_metric": "Duplication", + "amount": 5.0, + "applied_to": [ + "Duplication" + ], + "issue": "Found 15 duplicate code patterns" + }, + { + "source_metric": "StructuralComplexity", + "amount": 3.0, + "applied_to": [ + "StructuralComplexity" + ], + "issue": "High cognitive complexity: 25" + }, + { + "source_metric": "StructuralComplexity", + "amount": 9.0, + "applied_to": [ + "StructuralComplexity" + ], + "issue": "High cyclomatic complexity: 48" + } + ], + "critical_defects_count": 0, + "has_critical_defects": false, + "has_contract_coverage": false + }, + "components": { + "complexity_breakdown": {}, + "duplication_sources": [], + "coupling_dependencies": [], + "doc_missing_items": [], + "consistency_violations": [] + }, + "git_context": null + }, + "./src/rhl/fix.rs": { + "content_hash": [ + 217, + 36, + 90, + 193, + 144, + 25, + 91, + 48, + 160, + 178, + 71, + 17, + 249, + 235, + 21, + 249, + 162, + 26, + 87, + 199, + 168, + 212, + 27, + 154, + 38, + 140, + 168, 181, 86, 46, @@ -30289,38 +30803,38 @@ }, "./src/rhl/fmt.rs": { "content_hash": [ - 47, - 28, - 196, - 39, - 208, - 229, - 187, - 99, - 9, - 228, - 30, - 220, - 163, - 169, - 141, - 4, - 171, - 202, - 98, - 201, + 100, + 243, + 222, + 164, + 145, + 162, + 65, + 130, 234, - 58, - 228, - 52, - 179, - 25, - 5, - 17, - 155, - 179, - 133, - 181 + 76, + 243, + 92, + 144, + 169, + 139, + 120, + 95, + 159, + 21, + 177, + 218, + 253, + 82, + 181, + 92, + 198, + 73, + 200, + 47, + 206, + 69, + 81 ], "score": { "structural_complexity": 8.1, @@ -30374,40 +30888,127 @@ }, "git_context": null }, - "./src/rhl/mod.rs": { + "./src/rhl/lower.rs": { "content_hash": [ - 23, - 15, - 19, - 160, - 211, - 32, - 74, - 201, - 109, - 242, - 195, - 158, 166, - 208, - 150, - 240, - 15, - 78, - 179, - 152, - 215, + 116, + 206, + 120, + 84, + 21, + 98, + 230, + 31, + 237, + 120, + 12, + 220, + 55, + 90, 243, - 24, - 226, + 50, + 241, + 208, + 110, + 109, + 91, + 53, + 46, + 139, + 230, + 123, + 218, + 244, + 74, + 39, + 225 + ], + "score": { + "structural_complexity": 0.0, + "semantic_complexity": 20.0, + "duplication_ratio": 20.0, + "coupling_score": 15.0, + "doc_coverage": 10.0, + "consistency_score": 10.0, + "entropy_score": 5.0, + "total": 80.0, + "grade": "B+", + "confidence": 1.0, + "language": "Rust", + "file_path": "./src/rhl/lower.rs", + "penalties_applied": [ + { + "source_metric": "Duplication", + "amount": 5.0, + "applied_to": [ + "Duplication" + ], + "issue": "Found 77 duplicate code patterns" + }, + { + "source_metric": "StructuralComplexity", + "amount": 10.0, + "applied_to": [ + "StructuralComplexity" + ], + "issue": "High cognitive complexity: 88" + }, + { + "source_metric": "StructuralComplexity", + "amount": 15.0, + "applied_to": [ + "StructuralComplexity" + ], + "issue": "High cyclomatic complexity: 165" + } + ], + "critical_defects_count": 0, + "has_critical_defects": false, + "has_contract_coverage": false + }, + "components": { + "complexity_breakdown": {}, + "duplication_sources": [], + "coupling_dependencies": [], + "doc_missing_items": [], + "consistency_violations": [] + }, + "git_context": null + }, + "./src/rhl/mod.rs": { + "content_hash": [ + 12, + 40, + 19, 87, - 81, - 129, - 124, - 23, - 165, + 218, + 63, + 156, + 173, + 151, + 60, + 47, + 43, + 119, + 182, + 34, + 237, + 237, + 108, + 186, + 204, + 89, 172, - 255 + 240, + 9, + 238, + 26, + 133, + 45, + 111, + 253, + 229, + 250 ], "score": { "structural_complexity": 25.0, @@ -30516,6 +31117,148 @@ }, "git_context": null }, + "./src/rhl/receipt.rs": { + "content_hash": [ + 120, + 79, + 105, + 100, + 204, + 149, + 56, + 255, + 253, + 141, + 254, + 121, + 104, + 126, + 86, + 180, + 168, + 81, + 209, + 91, + 182, + 60, + 145, + 85, + 225, + 62, + 178, + 34, + 139, + 115, + 188, + 48 + ], + "score": { + "structural_complexity": 25.0, + "semantic_complexity": 20.0, + "duplication_ratio": 20.0, + "coupling_score": 15.0, + "doc_coverage": 10.0, + "consistency_score": 10.0, + "entropy_score": 6.0, + "total": 96.36363, + "grade": "A+", + "confidence": 1.0, + "language": "Rust", + "file_path": "./src/rhl/receipt.rs", + "penalties_applied": [ + { + "source_metric": "Duplication", + "amount": 4.0, + "applied_to": [ + "Duplication" + ], + "issue": "Found 8 duplicate code patterns" + } + ], + "critical_defects_count": 0, + "has_critical_defects": false, + "has_contract_coverage": false + }, + "components": { + "complexity_breakdown": {}, + "duplication_sources": [], + "coupling_dependencies": [], + "doc_missing_items": [], + "consistency_violations": [] + }, + "git_context": null + }, + "./src/rhl/runtime.rs": { + "content_hash": [ + 213, + 200, + 189, + 210, + 222, + 185, + 111, + 81, + 220, + 193, + 67, + 174, + 236, + 158, + 220, + 0, + 200, + 18, + 195, + 187, + 215, + 66, + 179, + 111, + 158, + 31, + 18, + 170, + 9, + 189, + 121, + 138 + ], + "score": { + "structural_complexity": 25.0, + "semantic_complexity": 20.0, + "duplication_ratio": 20.0, + "coupling_score": 15.0, + "doc_coverage": 10.0, + "consistency_score": 10.0, + "entropy_score": 5.0, + "total": 95.454544, + "grade": "A+", + "confidence": 1.0, + "language": "Rust", + "file_path": "./src/rhl/runtime.rs", + "penalties_applied": [ + { + "source_metric": "Duplication", + "amount": 5.0, + "applied_to": [ + "Duplication" + ], + "issue": "Found 11 duplicate code patterns" + } + ], + "critical_defects_count": 0, + "has_critical_defects": false, + "has_contract_coverage": false + }, + "components": { + "complexity_breakdown": {}, + "duplication_sources": [], + "coupling_dependencies": [], + "doc_missing_items": [], + "consistency_violations": [] + }, + "git_context": null + }, "./src/rhl/tree.rs": { "content_hash": [ 0, @@ -30597,38 +31340,38 @@ }, "./src/rhl/vocab.rs": { "content_hash": [ - 82, - 157, - 218, - 111, - 150, - 90, - 240, - 213, - 75, - 108, - 129, - 222, - 235, - 215, - 164, - 239, - 158, - 102, + 30, + 231, + 136, + 64, 176, - 103, - 2, - 192, - 203, - 153, - 188, - 235, - 203, - 133, - 180, + 55, + 65, + 151, + 117, + 198, + 230, + 246, + 236, + 130, + 142, + 201, + 212, + 174, + 17, + 106, + 76, + 204, + 176, + 148, + 173, + 32, + 60, 53, - 44, - 136 + 74, + 130, + 101, + 8 ], "score": { "structural_complexity": 25.0, @@ -30668,38 +31411,38 @@ }, "./src/rhl/vocab_cli.rs": { "content_hash": [ - 150, - 136, - 0, - 80, - 6, - 93, - 194, - 220, - 236, - 229, + 154, + 245, + 32, + 137, + 20, + 12, + 46, + 253, + 203, + 22, + 130, + 213, + 75, + 98, 120, - 218, - 255, - 60, - 41, - 166, - 251, - 176, - 79, + 151, 58, - 234, - 54, - 124, - 164, - 255, - 232, - 102, - 67, - 21, - 104, - 139, - 23 + 82, + 125, + 247, + 90, + 251, + 195, + 108, + 209, + 105, + 140, + 31, + 154, + 180, + 203, + 224 ], "score": { "structural_complexity": 25.0, @@ -30721,7 +31464,7 @@ "applied_to": [ "Duplication" ], - "issue": "Found 20 duplicate code patterns" + "issue": "Found 21 duplicate code patterns" } ], "critical_defects_count": 0, @@ -31917,48 +32660,48 @@ }, "./src/runtime/builtin_init.rs": { "content_hash": [ - 71, - 240, - 111, - 6, - 57, - 3, - 241, - 181, - 106, - 249, - 4, 27, - 134, - 245, - 52, - 200, - 166, - 203, - 197, - 208, - 211, - 102, - 176, - 146, + 113, + 250, + 88, + 59, + 70, + 2, 9, - 105, - 228, - 117, 24, - 13, - 119, - 31 + 147, + 180, + 138, + 7, + 230, + 128, + 27, + 81, + 84, + 137, + 236, + 152, + 240, + 48, + 70, + 157, + 227, + 151, + 8, + 70, + 167, + 93, + 234 ], "score": { "structural_complexity": 8.0, "semantic_complexity": 20.0, - "duplication_ratio": 15.33933, + "duplication_ratio": 15.344687, "coupling_score": 15.0, "doc_coverage": 10.0, "consistency_score": 10.0, "entropy_score": 5.0, - "total": 83.339325, + "total": 83.34469, "grade": "B+", "confidence": 1.0, "language": "Rust", @@ -31974,7 +32717,7 @@ }, { "source_metric": "Duplication", - "amount": 4.6606703, + "amount": 4.6553125, "applied_to": [ "Duplication" ], @@ -32976,48 +33719,48 @@ }, "./src/runtime/eval_array.rs": { "content_hash": [ - 99, - 216, - 180, - 159, - 241, - 4, - 181, - 167, - 2, - 215, - 163, + 232, + 213, + 1, + 12, + 98, + 62, + 235, + 113, + 73, + 0, + 36, 223, - 243, + 166, 2, - 208, + 16, 248, - 13, - 66, - 245, - 177, - 207, - 242, - 175, - 173, - 241, - 156, - 110, - 252, + 51, + 78, + 228, + 253, + 68, + 243, 106, - 65, - 167, - 117 + 151, + 14, + 78, + 233, + 43, + 49, + 72, + 122, + 150 ], "score": { "structural_complexity": 0.0, "semantic_complexity": 20.0, - "duplication_ratio": 15.015873, + "duplication_ratio": 16.146435, "coupling_score": 15.0, "doc_coverage": 10.0, "consistency_score": 10.0, "entropy_score": 5.0, - "total": 75.01587, + "total": 76.14644, "grade": "B", "confidence": 1.0, "language": "Rust", @@ -33029,15 +33772,15 @@ "applied_to": [ "Duplication" ], - "issue": "Found 71 duplicate code patterns" + "issue": "Found 107 duplicate code patterns" }, { "source_metric": "Duplication", - "amount": 4.984127, + "amount": 3.8535645, "applied_to": [ "Duplication" ], - "issue": "Code duplication: 24.9%" + "issue": "Code duplication: 19.3%" }, { "source_metric": "StructuralComplexity", @@ -33045,7 +33788,7 @@ "applied_to": [ "StructuralComplexity" ], - "issue": "High cognitive complexity: 100" + "issue": "High cognitive complexity: 113" }, { "source_metric": "StructuralComplexity", @@ -33053,7 +33796,7 @@ "applied_to": [ "StructuralComplexity" ], - "issue": "High cyclomatic complexity: 131" + "issue": "High cyclomatic complexity: 169" } ], "critical_defects_count": 0, @@ -33071,48 +33814,48 @@ }, "./src/runtime/eval_builtin.rs": { "content_hash": [ - 144, - 156, - 156, - 68, 138, - 196, - 168, - 241, + 227, 181, - 134, - 115, - 188, - 72, - 104, - 97, - 236, - 74, - 201, - 234, - 206, - 14, - 252, - 225, - 24, - 91, - 73, - 19, - 189, - 104, + 181, + 50, + 100, 44, + 142, + 149, + 245, + 17, + 117, + 11, + 151, + 7, + 146, + 112, + 150, + 113, + 117, + 26, + 2, + 38, + 26, + 98, + 171, + 157, + 114, + 15, + 219, 78, - 183 + 177 ], "score": { "structural_complexity": 0.0, "semantic_complexity": 20.0, - "duplication_ratio": 16.692015, + "duplication_ratio": 16.709108, "coupling_score": 15.0, "doc_coverage": 10.0, "consistency_score": 10.0, "entropy_score": 5.0, - "total": 76.69202, + "total": 76.70911, "grade": "B", "confidence": 1.0, "language": "Rust", @@ -33124,11 +33867,11 @@ "applied_to": [ "Duplication" ], - "issue": "Found 60 duplicate code patterns" + "issue": "Found 55 duplicate code patterns" }, { "source_metric": "Duplication", - "amount": 3.3079848, + "amount": 3.2908914, "applied_to": [ "Duplication" ], @@ -33140,7 +33883,7 @@ "applied_to": [ "StructuralComplexity" ], - "issue": "High cognitive complexity: 137" + "issue": "High cognitive complexity: 127" }, { "source_metric": "StructuralComplexity", @@ -33148,7 +33891,7 @@ "applied_to": [ "StructuralComplexity" ], - "issue": "High cyclomatic complexity: 241" + "issue": "High cyclomatic complexity: 234" } ], "critical_defects_count": 0, @@ -33166,48 +33909,48 @@ }, "./src/runtime/eval_builtin_fs.rs": { "content_hash": [ - 134, - 158, - 181, - 66, + 163, + 130, + 120, + 222, + 168, + 53, + 74, + 214, + 145, + 154, + 187, + 239, + 71, + 245, 252, - 0, - 50, - 254, - 218, - 173, - 29, - 127, - 247, - 169, - 127, - 152, - 204, - 89, + 175, + 132, + 111, 96, - 188, - 166, - 254, - 113, - 199, - 152, - 22, - 194, - 210, - 224, - 48, - 253, - 98 + 156, + 196, + 158, + 94, + 79, + 162, + 42, + 205, + 107, + 216, + 60, + 65, + 128 ], "score": { "structural_complexity": 0.0, "semantic_complexity": 20.0, - "duplication_ratio": 15.029411, + "duplication_ratio": 15.007385, "coupling_score": 15.0, "doc_coverage": 10.0, "consistency_score": 10.0, "entropy_score": 5.0, - "total": 75.02941, + "total": 75.007385, "grade": "B", "confidence": 1.0, "language": "Rust", @@ -33223,11 +33966,11 @@ }, { "source_metric": "Duplication", - "amount": 4.970588, + "amount": 4.9926143, "applied_to": [ "Duplication" ], - "issue": "Code duplication: 24.9%" + "issue": "Code duplication: 25.0%" }, { "source_metric": "StructuralComplexity", @@ -34924,48 +35667,48 @@ }, "./src/runtime/eval_method_dispatch.rs": { "content_hash": [ - 225, - 30, - 118, - 221, - 177, + 127, 124, - 0, + 111, + 187, + 173, + 71, + 41, + 166, + 124, + 31, 64, + 89, + 167, + 220, + 178, + 164, + 134, + 70, + 142, + 149, + 5, + 104, 174, - 37, - 71, - 133, - 249, - 175, - 203, - 212, - 82, - 92, - 210, - 105, - 28, - 16, 246, - 224, - 49, - 108, - 237, - 203, - 199, - 162, - 45, - 9 + 200, + 96, + 86, + 52, + 230, + 16, + 218, + 13 ], "score": { "structural_complexity": 0.0, "semantic_complexity": 19.0, - "duplication_ratio": 15.203762, + "duplication_ratio": 15.150602, "coupling_score": 15.0, "doc_coverage": 10.0, "consistency_score": 10.0, "entropy_score": 5.0, - "total": 74.203766, + "total": 74.150604, "grade": "B-", "confidence": 1.0, "language": "Rust", @@ -34977,15 +35720,15 @@ "applied_to": [ "Duplication" ], - "issue": "Found 68 duplicate code patterns" + "issue": "Found 71 duplicate code patterns" }, { "source_metric": "Duplication", - "amount": 4.7962384, + "amount": 4.8493977, "applied_to": [ "Duplication" ], - "issue": "Code duplication: 24.0%" + "issue": "Code duplication: 24.2%" }, { "source_metric": "StructuralComplexity", @@ -34993,7 +35736,7 @@ "applied_to": [ "StructuralComplexity" ], - "issue": "High cognitive complexity: 81" + "issue": "High cognitive complexity: 83" }, { "source_metric": "StructuralComplexity", @@ -35001,7 +35744,7 @@ "applied_to": [ "StructuralComplexity" ], - "issue": "High cyclomatic complexity: 122" + "issue": "High cyclomatic complexity: 125" }, { "source_metric": "SemanticComplexity", @@ -35028,47 +35771,47 @@ "./src/runtime/eval_operations.rs": { "content_hash": [ 145, - 0, - 130, - 59, + 124, + 227, + 136, + 252, + 51, + 231, + 54, + 93, + 78, + 231, + 99, + 44, + 218, + 82, + 27, + 147, + 48, + 54, + 189, + 218, + 183, + 57, + 254, 64, + 192, 17, - 240, - 78, - 127, - 180, - 3, - 149, - 135, - 213, - 130, - 194, - 61, + 193, + 184, + 34, 174, - 167, - 104, - 241, - 145, - 146, - 68, - 3, - 37, - 150, - 167, - 227, - 96, - 172, - 117 + 16 ], "score": { "structural_complexity": 0.0, "semantic_complexity": 20.0, - "duplication_ratio": 15.770863, + "duplication_ratio": 15.779817, "coupling_score": 15.0, "doc_coverage": 10.0, "consistency_score": 10.0, "entropy_score": 5.0, - "total": 75.77086, + "total": 75.779816, "grade": "B", "confidence": 1.0, "language": "Rust", @@ -35084,7 +35827,7 @@ }, { "source_metric": "Duplication", - "amount": 4.2291374, + "amount": 4.2201834, "applied_to": [ "Duplication" ], @@ -35104,7 +35847,102 @@ "applied_to": [ "StructuralComplexity" ], - "issue": "High cyclomatic complexity: 148" + "issue": "High cyclomatic complexity: 149" + } + ], + "critical_defects_count": 0, + "has_critical_defects": false, + "has_contract_coverage": false + }, + "components": { + "complexity_breakdown": {}, + "duplication_sources": [], + "coupling_dependencies": [], + "doc_missing_items": [], + "consistency_violations": [] + }, + "git_context": null + }, + "./src/runtime/eval_option_methods.rs": { + "content_hash": [ + 138, + 7, + 107, + 168, + 198, + 45, + 95, + 6, + 188, + 102, + 45, + 84, + 216, + 230, + 233, + 190, + 217, + 28, + 9, + 126, + 239, + 34, + 94, + 195, + 179, + 123, + 208, + 120, + 167, + 107, + 131, + 157 + ], + "score": { + "structural_complexity": 7.6, + "semantic_complexity": 20.0, + "duplication_ratio": 17.290323, + "coupling_score": 15.0, + "doc_coverage": 10.0, + "consistency_score": 10.0, + "entropy_score": 5.0, + "total": 84.89032, + "grade": "B+", + "confidence": 1.0, + "language": "Rust", + "file_path": "./src/runtime/eval_option_methods.rs", + "penalties_applied": [ + { + "source_metric": "Duplication", + "amount": 5.0, + "applied_to": [ + "Duplication" + ], + "issue": "Found 26 duplicate code patterns" + }, + { + "source_metric": "Duplication", + "amount": 2.7096775, + "applied_to": [ + "Duplication" + ], + "issue": "Code duplication: 13.5%" + }, + { + "source_metric": "StructuralComplexity", + "amount": 2.4, + "applied_to": [ + "StructuralComplexity" + ], + "issue": "High cognitive complexity: 23" + }, + { + "source_metric": "StructuralComplexity", + "amount": 15.0, + "applied_to": [ + "StructuralComplexity" + ], + "issue": "High cyclomatic complexity: 60" } ], "critical_defects_count": 0, @@ -35502,38 +36340,38 @@ }, "./src/runtime/eval_string_methods.rs": { "content_hash": [ - 127, - 5, - 254, - 134, - 19, - 214, - 176, - 86, - 48, - 184, - 115, - 187, - 179, - 171, - 40, - 222, - 19, - 112, - 110, + 227, + 31, + 11, + 123, + 87, + 119, + 15, + 251, + 157, + 10, + 246, + 247, + 53, + 114, + 136, + 186, + 203, + 20, + 165, + 212, + 154, + 97, + 175, + 114, + 32, + 106, + 189, 120, - 240, - 124, - 249, - 121, - 231, - 91, - 54, - 158, - 198, - 94, - 140, - 70 + 132, + 230, + 34, + 166 ], "score": { "structural_complexity": 0.0, @@ -35698,6 +36536,93 @@ }, "git_context": null }, + "./src/runtime/fmt_spec.rs": { + "content_hash": [ + 202, + 42, + 183, + 191, + 148, + 74, + 29, + 14, + 218, + 233, + 179, + 161, + 51, + 53, + 35, + 188, + 175, + 199, + 51, + 214, + 75, + 19, + 41, + 253, + 203, + 95, + 225, + 113, + 80, + 33, + 46, + 211 + ], + "score": { + "structural_complexity": 0.0, + "semantic_complexity": 20.0, + "duplication_ratio": 20.0, + "coupling_score": 15.0, + "doc_coverage": 10.0, + "consistency_score": 10.0, + "entropy_score": 5.0, + "total": 80.0, + "grade": "B+", + "confidence": 1.0, + "language": "Rust", + "file_path": "./src/runtime/fmt_spec.rs", + "penalties_applied": [ + { + "source_metric": "Duplication", + "amount": 5.0, + "applied_to": [ + "Duplication" + ], + "issue": "Found 34 duplicate code patterns" + }, + { + "source_metric": "StructuralComplexity", + "amount": 10.0, + "applied_to": [ + "StructuralComplexity" + ], + "issue": "High cognitive complexity: 54" + }, + { + "source_metric": "StructuralComplexity", + "amount": 15.0, + "applied_to": [ + "StructuralComplexity" + ], + "issue": "High cyclomatic complexity: 108" + } + ], + "critical_defects_count": 0, + "has_critical_defects": false, + "has_contract_coverage": false + }, + "components": { + "complexity_breakdown": {}, + "duplication_sources": [], + "coupling_dependencies": [], + "doc_missing_items": [], + "consistency_violations": [] + }, + "git_context": null + }, "./src/runtime/gc.rs": { "content_hash": [ 200, @@ -36056,48 +36981,48 @@ }, "./src/runtime/interpreter.rs": { "content_hash": [ - 235, - 176, - 143, - 176, - 209, - 29, - 242, - 232, - 81, - 144, - 134, - 137, - 222, - 202, - 4, - 59, - 124, - 75, - 185, - 170, - 75, - 219, - 122, - 43, - 253, + 23, + 61, + 6, + 5, + 114, + 67, + 3, + 252, + 56, + 45, + 53, + 189, + 146, + 72, + 250, + 37, + 30, 203, 67, - 138, - 63, - 201, - 61, - 151 + 102, + 152, + 98, + 186, + 70, + 33, + 89, + 244, + 43, + 176, + 96, + 165, + 222 ], "score": { "structural_complexity": 0.0, "semantic_complexity": 20.0, - "duplication_ratio": 17.538237, + "duplication_ratio": 17.532846, "coupling_score": 15.0, "doc_coverage": 10.0, "consistency_score": 10.0, "entropy_score": 5.0, - "total": 77.53824, + "total": 77.532845, "grade": "B", "confidence": 1.0, "language": "Rust", @@ -36113,7 +37038,7 @@ }, { "source_metric": "Duplication", - "amount": 2.4617627, + "amount": 2.4671533, "applied_to": [ "Duplication" ], @@ -36151,48 +37076,48 @@ }, "./src/runtime/interpreter_control_flow.rs": { "content_hash": [ - 254, - 239, - 119, - 191, - 31, + 106, + 11, + 69, + 124, + 155, + 235, + 132, + 66, 73, - 217, - 197, - 249, - 154, - 192, - 5, - 67, - 135, - 238, - 16, - 214, - 102, - 161, - 127, - 23, 96, - 184, - 204, - 139, - 42, - 117, - 164, - 69, - 28, - 82, - 202 + 124, + 193, + 210, + 168, + 142, + 113, + 51, + 66, + 52, + 189, + 156, + 166, + 94, + 237, + 156, + 178, + 126, + 38, + 191, + 102, + 210, + 78 ], "score": { "structural_complexity": 0.0, "semantic_complexity": 20.0, - "duplication_ratio": 13.962264, + "duplication_ratio": 14.064603, "coupling_score": 15.0, "doc_coverage": 10.0, "consistency_score": 10.0, "entropy_score": 5.0, - "total": 73.962265, + "total": 74.064606, "grade": "B-", "confidence": 1.0, "language": "Rust", @@ -36204,15 +37129,15 @@ "applied_to": [ "Duplication" ], - "issue": "Found 184 duplicate code patterns" + "issue": "Found 185 duplicate code patterns" }, { "source_metric": "Duplication", - "amount": 6.037736, + "amount": 5.935397, "applied_to": [ "Duplication" ], - "issue": "Code duplication: 30.2%" + "issue": "Code duplication: 29.7%" }, { "source_metric": "StructuralComplexity", @@ -36220,7 +37145,7 @@ "applied_to": [ "StructuralComplexity" ], - "issue": "High cognitive complexity: 99" + "issue": "High cognitive complexity: 102" }, { "source_metric": "StructuralComplexity", @@ -36228,7 +37153,7 @@ "applied_to": [ "StructuralComplexity" ], - "issue": "High cyclomatic complexity: 111" + "issue": "High cyclomatic complexity: 115" } ], "critical_defects_count": 0, @@ -36347,50 +37272,121 @@ }, "git_context": null }, + "./src/runtime/interpreter_format_calls.rs": { + "content_hash": [ + 38, + 187, + 141, + 98, + 19, + 34, + 99, + 0, + 152, + 100, + 187, + 66, + 53, + 243, + 167, + 180, + 174, + 39, + 96, + 212, + 74, + 251, + 234, + 254, + 223, + 175, + 243, + 168, + 113, + 184, + 157, + 111 + ], + "score": { + "structural_complexity": 25.0, + "semantic_complexity": 20.0, + "duplication_ratio": 20.0, + "coupling_score": 15.0, + "doc_coverage": 10.0, + "consistency_score": 10.0, + "entropy_score": 6.0, + "total": 96.36363, + "grade": "A+", + "confidence": 1.0, + "language": "Rust", + "file_path": "./src/runtime/interpreter_format_calls.rs", + "penalties_applied": [ + { + "source_metric": "Duplication", + "amount": 4.0, + "applied_to": [ + "Duplication" + ], + "issue": "Found 8 duplicate code patterns" + } + ], + "critical_defects_count": 0, + "has_critical_defects": false, + "has_contract_coverage": false + }, + "components": { + "complexity_breakdown": {}, + "duplication_sources": [], + "coupling_dependencies": [], + "doc_missing_items": [], + "consistency_violations": [] + }, + "git_context": null + }, "./src/runtime/interpreter_functions.rs": { "content_hash": [ - 119, - 76, - 12, - 224, - 8, - 143, - 17, - 197, - 126, - 20, - 255, - 29, - 209, + 82, + 198, + 254, + 164, + 239, + 157, + 93, + 221, + 146, + 7, + 102, + 248, + 7, + 166, + 232, + 103, + 155, + 246, + 141, + 100, 208, - 25, - 229, - 187, - 173, - 37, - 156, - 194, - 176, - 241, - 46, - 194, - 253, - 95, - 119, - 49, - 95, - 135, - 72 + 129, + 216, + 179, + 203, + 132, + 215, + 143, + 81, + 60, + 203, + 30 ], "score": { "structural_complexity": 0.0, "semantic_complexity": 20.0, - "duplication_ratio": 14.461182, + "duplication_ratio": 14.469526, "coupling_score": 15.0, "doc_coverage": 10.0, "consistency_score": 10.0, "entropy_score": 5.0, - "total": 74.46118, + "total": 74.46953, "grade": "B-", "confidence": 1.0, "language": "Rust", @@ -36410,11 +37406,11 @@ "applied_to": [ "Duplication" ], - "issue": "Found 98 duplicate code patterns" + "issue": "Found 103 duplicate code patterns" }, { "source_metric": "Duplication", - "amount": 5.5388184, + "amount": 5.530474, "applied_to": [ "Duplication" ], @@ -36426,15 +37422,15 @@ "applied_to": [ "StructuralComplexity" ], - "issue": "High cognitive complexity: 114" + "issue": "High cognitive complexity: 116" }, { "source_metric": "StructuralComplexity", - "amount": 13.0, + "amount": 15.0, "applied_to": [ "StructuralComplexity" ], - "issue": "High cyclomatic complexity: 56" + "issue": "High cyclomatic complexity: 64" } ], "critical_defects_count": 0, @@ -36625,48 +37621,48 @@ }, "./src/runtime/interpreter_methods_actor.rs": { "content_hash": [ - 104, - 239, - 153, - 135, - 143, - 185, - 32, - 184, - 218, + 103, + 79, + 111, 13, - 140, - 71, - 199, - 17, - 237, - 162, - 14, - 239, + 208, + 118, + 230, + 89, + 25, + 249, + 234, + 178, + 185, + 58, + 143, 52, - 17, - 144, - 80, - 80, - 152, - 122, - 15, - 42, + 221, + 151, + 176, + 134, + 116, + 43, + 138, + 73, + 208, 188, - 252, + 224, + 7, + 117, + 147, 103, - 241, - 8 + 143 ], "score": { "structural_complexity": 9.5, "semantic_complexity": 20.0, - "duplication_ratio": 17.407408, + "duplication_ratio": 17.421053, "coupling_score": 15.0, "doc_coverage": 10.0, "consistency_score": 10.0, "entropy_score": 5.0, - "total": 86.90741, + "total": 86.92105, "grade": "A-", "confidence": 1.0, "language": "Rust", @@ -36682,11 +37678,11 @@ }, { "source_metric": "Duplication", - "amount": 2.5925925, + "amount": 2.5789473, "applied_to": [ "Duplication" ], - "issue": "Code duplication: 13.0%" + "issue": "Code duplication: 12.9%" }, { "source_metric": "StructuralComplexity", @@ -36720,76 +37716,68 @@ }, "./src/runtime/interpreter_methods_dispatch.rs": { "content_hash": [ - 33, - 121, - 164, - 105, - 218, - 76, - 252, - 192, - 108, - 57, - 234, - 53, - 119, - 244, - 4, - 56, - 6, - 219, - 224, - 102, - 217, - 105, - 255, - 194, - 61, + 89, + 206, + 198, + 132, + 210, + 93, + 35, + 236, + 3, + 190, + 212, + 140, + 144, + 222, + 179, 16, - 205, - 232, - 28, - 19, - 79, - 102 + 201, + 239, + 237, + 47, + 27, + 17, + 147, + 102, + 50, + 163, + 4, + 65, + 132, + 54, + 104, + 153 ], "score": { "structural_complexity": 0.0, "semantic_complexity": 20.0, - "duplication_ratio": 13.974066, - "coupling_score": 11.4, + "duplication_ratio": 14.397871, + "coupling_score": 10.799999, "doc_coverage": 10.0, "consistency_score": 10.0, "entropy_score": 5.0, - "total": 70.37407, + "total": 70.197876, "grade": "B-", "confidence": 1.0, "language": "Rust", "file_path": "./src/runtime/interpreter_methods_dispatch.rs", "penalties_applied": [ - { - "source_metric": "StructuralComplexity", - "amount": 2.0, - "applied_to": [ - "StructuralComplexity" - ], - "issue": "Deep nesting: 6 levels" - }, { "source_metric": "Duplication", "amount": 5.0, "applied_to": [ "Duplication" ], - "issue": "Found 175 duplicate code patterns" + "issue": "Found 172 duplicate code patterns" }, { "source_metric": "Duplication", - "amount": 6.025934, + "amount": 5.602129, "applied_to": [ "Duplication" ], - "issue": "Code duplication: 30.1%" + "issue": "Code duplication: 28.0%" }, { "source_metric": "StructuralComplexity", @@ -36797,7 +37785,7 @@ "applied_to": [ "StructuralComplexity" ], - "issue": "High cognitive complexity: 122" + "issue": "High cognitive complexity: 61" }, { "source_metric": "StructuralComplexity", @@ -36805,15 +37793,15 @@ "applied_to": [ "StructuralComplexity" ], - "issue": "High cyclomatic complexity: 68" + "issue": "High cyclomatic complexity: 80" }, { "source_metric": "Coupling", - "amount": 3.6000001, + "amount": 4.2000003, "applied_to": [ "Coupling" ], - "issue": "Too many imports: 38" + "issue": "Too many imports: 41" } ], "critical_defects_count": 0, @@ -37100,48 +38088,48 @@ }, "./src/runtime/interpreter_misc_eval.rs": { "content_hash": [ - 241, - 131, - 128, - 101, - 104, - 2, - 83, - 210, + 43, + 151, + 82, 214, - 64, - 139, - 23, - 99, - 135, - 150, - 69, - 63, - 241, - 41, - 70, - 64, - 95, - 119, - 45, - 176, - 106, - 217, - 211, - 9, - 176, - 167, - 173 + 107, + 235, + 157, + 75, + 250, + 178, + 68, + 196, + 51, + 160, + 186, + 4, + 6, + 220, + 226, + 213, + 128, + 145, + 107, + 188, + 223, + 89, + 228, + 152, + 239, + 31, + 196, + 178 ], "score": { "structural_complexity": 0.0, "semantic_complexity": 18.5, - "duplication_ratio": 16.703568, + "duplication_ratio": 16.639118, "coupling_score": 15.0, "doc_coverage": 10.0, "consistency_score": 10.0, "entropy_score": 5.0, - "total": 75.20357, + "total": 75.139114, "grade": "B", "confidence": 1.0, "language": "Rust", @@ -37153,15 +38141,15 @@ "applied_to": [ "Duplication" ], - "issue": "Found 105 duplicate code patterns" + "issue": "Found 95 duplicate code patterns" }, { "source_metric": "Duplication", - "amount": 3.296433, + "amount": 3.3608816, "applied_to": [ "Duplication" ], - "issue": "Code duplication: 16.5%" + "issue": "Code duplication: 16.8%" }, { "source_metric": "StructuralComplexity", @@ -37169,7 +38157,7 @@ "applied_to": [ "StructuralComplexity" ], - "issue": "High cognitive complexity: 113" + "issue": "High cognitive complexity: 84" }, { "source_metric": "StructuralComplexity", @@ -37177,7 +38165,7 @@ "applied_to": [ "StructuralComplexity" ], - "issue": "High cyclomatic complexity: 126" + "issue": "High cyclomatic complexity: 111" }, { "source_metric": "SemanticComplexity", @@ -37456,48 +38444,48 @@ }, "./src/runtime/interpreter_types_class.rs": { "content_hash": [ - 224, - 22, + 150, + 140, + 146, + 6, + 5, + 240, 215, + 14, + 96, + 141, + 141, + 179, 161, - 40, - 155, - 41, - 177, - 103, - 249, - 171, - 145, - 0, - 139, - 235, - 254, - 43, - 17, - 68, - 56, - 222, - 229, - 15, - 137, - 246, - 216, - 178, - 46, - 149, - 112, - 236, - 127 + 214, + 197, + 108, + 247, + 244, + 52, + 65, + 247, + 105, + 241, + 37, + 232, + 111, + 7, + 189, + 98, + 132, + 20, + 107 ], "score": { "structural_complexity": 0.0, "semantic_complexity": 20.0, - "duplication_ratio": 16.80498, + "duplication_ratio": 16.882591, "coupling_score": 15.0, "doc_coverage": 10.0, "consistency_score": 10.0, "entropy_score": 5.0, - "total": 76.80498, + "total": 76.88259, "grade": "B", "confidence": 1.0, "language": "Rust", @@ -37517,15 +38505,15 @@ "applied_to": [ "Duplication" ], - "issue": "Found 62 duplicate code patterns" + "issue": "Found 61 duplicate code patterns" }, { "source_metric": "Duplication", - "amount": 3.195021, + "amount": 3.117409, "applied_to": [ "Duplication" ], - "issue": "Code duplication: 16.0%" + "issue": "Code duplication: 15.6%" }, { "source_metric": "StructuralComplexity", @@ -37533,7 +38521,7 @@ "applied_to": [ "StructuralComplexity" ], - "issue": "High cognitive complexity: 181" + "issue": "High cognitive complexity: 177" }, { "source_metric": "StructuralComplexity", @@ -38096,48 +39084,48 @@ }, "./src/runtime/mod.rs": { "content_hash": [ - 88, - 26, + 221, + 32, + 51, + 120, 157, - 61, - 98, - 235, - 223, - 16, - 27, - 60, - 244, - 250, - 27, - 124, - 140, - 175, - 214, - 153, - 103, 14, - 48, - 227, - 110, - 95, - 207, - 178, - 113, - 150, - 65, - 161, - 38, - 110 + 211, + 127, + 222, + 170, + 1, + 239, + 165, + 134, + 240, + 236, + 164, + 59, + 175, + 92, + 45, + 233, + 185, + 197, + 117, + 44, + 18, + 149, + 206, + 33, + 173, + 0 ], "score": { "structural_complexity": 25.0, "semantic_complexity": 20.0, - "duplication_ratio": 15.366972, + "duplication_ratio": 15.398633, "coupling_score": 15.0, "doc_coverage": 10.0, "consistency_score": 10.0, "entropy_score": 5.0, - "total": 91.2427, + "total": 91.27149, "grade": "A", "confidence": 1.0, "language": "Rust", @@ -38153,11 +39141,11 @@ }, { "source_metric": "Duplication", - "amount": 4.6330276, + "amount": 4.601367, "applied_to": [ "Duplication" ], - "issue": "Code duplication: 23.2%" + "issue": "Code duplication: 23.0%" } ], "critical_defects_count": 0, @@ -38816,48 +39804,48 @@ }, "./src/runtime/repl/evaluation.rs": { "content_hash": [ + 44, + 74, + 136, + 253, 83, - 32, - 118, - 98, - 37, - 80, - 57, - 87, - 140, - 224, - 160, - 238, - 102, - 94, - 205, - 165, - 146, - 197, - 68, - 225, - 241, - 205, + 233, + 166, + 23, 138, - 89, 77, - 34, - 205, - 102, - 30, - 33, - 157, - 136 + 131, + 140, + 240, + 11, + 241, + 222, + 173, + 3, + 179, + 82, + 202, + 80, + 142, + 179, + 245, + 227, + 56, + 47, + 210, + 70, + 199, + 85 ], "score": { - "structural_complexity": 1.2999992, + "structural_complexity": 0.39999962, "semantic_complexity": 20.0, - "duplication_ratio": 16.987951, + "duplication_ratio": 17.1537, "coupling_score": 15.0, "doc_coverage": 10.0, "consistency_score": 10.0, "entropy_score": 5.0, - "total": 78.28795, + "total": 77.553696, "grade": "B", "confidence": 1.0, "language": "Rust", @@ -38873,19 +39861,19 @@ }, { "source_metric": "Duplication", - "amount": 3.0120482, + "amount": 2.8462996, "applied_to": [ "Duplication" ], - "issue": "Code duplication: 15.1%" + "issue": "Code duplication: 14.2%" }, { "source_metric": "StructuralComplexity", - "amount": 8.700001, + "amount": 9.6, "applied_to": [ "StructuralComplexity" ], - "issue": "High cognitive complexity: 44" + "issue": "High cognitive complexity: 47" }, { "source_metric": "StructuralComplexity", @@ -38893,7 +39881,7 @@ "applied_to": [ "StructuralComplexity" ], - "issue": "High cyclomatic complexity: 75" + "issue": "High cyclomatic complexity: 79" } ], "critical_defects_count": 0, @@ -39960,49 +40948,49 @@ }, "./src/runtime/value.rs": { "content_hash": [ - 230, - 96, - 88, - 3, + 43, + 11, 91, - 102, - 214, - 29, - 178, - 151, - 92, - 212, - 8, - 159, - 96, - 109, - 136, - 125, - 127, - 57, - 219, - 108, - 159, - 22, - 177, - 71, + 215, + 99, + 13, + 95, + 51, + 143, + 117, + 200, + 53, + 105, + 10, + 141, + 46, + 152, + 228, 91, - 248, - 42, - 205, - 250, - 210 + 40, + 126, + 185, + 220, + 244, + 188, + 126, + 162, + 208, + 93, + 237, + 226, + 176 ], "score": { - "structural_complexity": 21.5, + "structural_complexity": 19.5, "semantic_complexity": 20.0, "duplication_ratio": 20.0, "coupling_score": 15.0, "doc_coverage": 10.0, "consistency_score": 10.0, "entropy_score": 5.0, - "total": 92.27273, - "grade": "A", + "total": 99.5, + "grade": "A+", "confidence": 1.0, "language": "Rust", "file_path": "./src/runtime/value.rs", @@ -40013,15 +41001,15 @@ "applied_to": [ "Duplication" ], - "issue": "Found 12 duplicate code patterns" + "issue": "Found 15 duplicate code patterns" }, { "source_metric": "StructuralComplexity", - "amount": 3.5, + "amount": 5.5, "applied_to": [ "StructuralComplexity" ], - "issue": "High cyclomatic complexity: 37" + "issue": "High cyclomatic complexity: 41" } ], "critical_defects_count": 0, @@ -40039,48 +41027,48 @@ }, "./src/runtime/value_format.rs": { "content_hash": [ - 244, - 26, - 54, - 42, - 83, - 128, - 153, - 106, - 197, - 57, - 53, - 152, - 194, - 175, - 174, - 145, + 158, + 93, + 72, + 142, 101, - 74, - 39, - 45, - 167, - 215, - 252, + 110, + 113, + 245, + 3, + 109, + 231, + 40, 226, - 185, + 34, + 147, + 135, + 235, + 102, + 47, + 58, + 162, + 118, + 27, + 114, + 168, + 230, 23, - 78, - 96, - 64, - 203, - 189, - 200 + 17, + 216, + 156, + 35, + 239 ], "score": { - "structural_complexity": 14.599998, + "structural_complexity": 11.799999, "semantic_complexity": 20.0, "duplication_ratio": 20.0, "coupling_score": 15.0, "doc_coverage": 10.0, "consistency_score": 10.0, "entropy_score": 5.0, - "total": 94.6, + "total": 91.8, "grade": "A", "confidence": 1.0, "language": "Rust", @@ -40100,15 +41088,23 @@ "applied_to": [ "Duplication" ], - "issue": "Found 30 duplicate code patterns" + "issue": "Found 32 duplicate code patterns" }, { "source_metric": "StructuralComplexity", - "amount": 8.400001, + "amount": 8.700001, "applied_to": [ "StructuralComplexity" ], - "issue": "High cognitive complexity: 43" + "issue": "High cognitive complexity: 44" + }, + { + "source_metric": "StructuralComplexity", + "amount": 2.5, + "applied_to": [ + "StructuralComplexity" + ], + "issue": "High cyclomatic complexity: 35" } ], "critical_defects_count": 0, @@ -40300,48 +41296,48 @@ }, "./src/server/watcher.rs": { "content_hash": [ - 128, - 138, - 162, - 107, - 156, - 92, - 30, - 145, - 190, - 93, - 114, - 66, + 135, + 46, + 184, + 173, + 85, + 74, + 246, + 78, + 55, + 160, + 176, + 191, 16, - 112, - 1, - 151, - 174, - 192, - 162, - 105, - 169, - 121, - 231, - 105, - 44, - 157, - 151, - 43, - 187, - 198, - 24, - 114 + 229, + 159, + 218, + 51, + 104, + 219, + 178, + 63, + 160, + 156, + 219, + 116, + 200, + 152, + 186, + 205, + 177, + 60, + 187 ], "score": { - "structural_complexity": 25.0, + "structural_complexity": 24.7, "semantic_complexity": 20.0, - "duplication_ratio": 16.556292, + "duplication_ratio": 17.41573, "coupling_score": 15.0, "doc_coverage": 10.0, "consistency_score": 10.0, "entropy_score": 5.0, - "total": 92.3239, + "total": 92.83248, "grade": "A", "confidence": 1.0, "language": "Rust", @@ -40357,11 +41353,19 @@ }, { "source_metric": "Duplication", - "amount": 3.4437087, + "amount": 2.5842695, "applied_to": [ "Duplication" ], - "issue": "Code duplication: 17.2%" + "issue": "Code duplication: 12.9%" + }, + { + "source_metric": "StructuralComplexity", + "amount": 0.3, + "applied_to": [ + "StructuralComplexity" + ], + "issue": "High cognitive complexity: 16" } ], "critical_defects_count": 0, @@ -40906,6 +41910,77 @@ }, "git_context": null }, + "./src/stdlib/hash.rs": { + "content_hash": [ + 245, + 136, + 56, + 33, + 87, + 85, + 153, + 67, + 145, + 119, + 239, + 236, + 114, + 249, + 67, + 111, + 131, + 234, + 2, + 234, + 140, + 58, + 157, + 139, + 238, + 1, + 24, + 139, + 216, + 107, + 167, + 149 + ], + "score": { + "structural_complexity": 25.0, + "semantic_complexity": 20.0, + "duplication_ratio": 20.0, + "coupling_score": 15.0, + "doc_coverage": 10.0, + "consistency_score": 10.0, + "entropy_score": 7.0, + "total": 97.27273, + "grade": "A+", + "confidence": 1.0, + "language": "Rust", + "file_path": "./src/stdlib/hash.rs", + "penalties_applied": [ + { + "source_metric": "Duplication", + "amount": 3.0, + "applied_to": [ + "Duplication" + ], + "issue": "Found 6 duplicate code patterns" + } + ], + "critical_defects_count": 0, + "has_critical_defects": false, + "has_contract_coverage": false + }, + "components": { + "complexity_breakdown": {}, + "duplication_sources": [], + "coupling_dependencies": [], + "doc_missing_items": [], + "consistency_violations": [] + }, + "git_context": null + }, "./src/stdlib/html.rs": { "content_hash": [ 79, @@ -41232,38 +42307,38 @@ }, "./src/stdlib/mod.rs": { "content_hash": [ - 55, - 9, - 236, - 68, - 184, - 210, - 158, - 74, - 228, - 40, - 201, - 244, - 244, + 117, + 213, + 217, + 29, + 113, + 111, + 139, + 37, + 26, + 212, + 6, + 155, 73, - 115, - 239, - 105, - 78, - 201, - 222, - 220, - 143, - 120, - 4, - 39, - 144, - 60, - 209, - 122, - 43, - 181, - 140 + 91, + 185, + 32, + 197, + 242, + 134, + 168, + 99, + 86, + 184, + 241, + 140, + 194, + 103, + 47, + 69, + 35, + 172, + 190 ], "score": { "structural_complexity": 25.0, @@ -41777,38 +42852,38 @@ }, "./src/stdlib/trueno_bridge.rs": { "content_hash": [ - 208, - 228, - 201, - 153, - 221, - 3, - 80, - 80, - 80, - 196, - 29, - 242, - 56, + 180, + 174, + 192, + 124, + 172, + 94, + 141, + 120, + 8, + 132, + 61, + 69, + 97, + 94, + 180, + 86, + 216, + 42, + 200, + 207, + 247, + 224, 5, + 123, 223, - 13, - 158, - 239, - 62, - 175, - 108, - 238, - 75, - 34, - 70, - 240, - 146, - 152, - 113, - 70, - 75, - 31 + 15, + 78, + 171, + 176, + 9, + 221, + 44 ], "score": { "structural_complexity": 25.0, @@ -44144,14 +45219,14 @@ } }, "summary": { - "total_files": 519, - "avg_score": 90.31551, + "total_files": 532, + "avg_score": 90.27216, "grade_distribution": { - "A+": 189, - "A": 176, - "A-": 30, - "B+": 28, - "B": 67, + "A+": 196, + "A": 175, + "A-": 33, + "B+": 30, + "B": 69, "B-": 26, "C": 1, "F": 2 @@ -44159,7 +45234,7 @@ "languages": { "JavaScript": 14, "Python": 4, - "Rust": 497, + "Rust": 510, "TypeScript": 4 } } diff --git a/CHANGELOG.md b/CHANGELOG.md index 99a24171c..6adbd1400 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,206 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed — G2 part 2 (RHLGA-1): the next round of workspace test failures + +Behaviour changes a program can observe are marked **(behaviour)**. + +- **(behaviour)** Method receivers follow the spec (§7.11): a bare `self` is + owned, and becomes `&mut self` when the method mutates it. Write `&self` for + a borrowing getter. This reverses the 2026-01 change that always borrowed. + Mutation is detected through nested fields, indexes, `++`/`--`, mutating std + methods on a field (`push`, `insert`, …) and calls to other mutating + methods of the same impl. The mutating std methods include `resize` and + `extend_from_slice`, and passing `&mut self.field` to a call mutates `self`. + A trait default method taking an owned `self` gets + `where Self: Sized`, so it compiles. +- A function or method named with a Rust reserved word (`do`, `box`, `typeof`, …) + is emitted as a raw identifier (`r#do`) at definition and call. +- `ruchy fmt` keeps `::` paths. `i32::MAX` transpiles as a path. A user-defined + `range` function is no longer mistaken for the builtin. An awaiting `main` + with a declared return type (`-> Result<(), String>`) compiles. +- `module::function()` calls keep `::` in transpiled Rust, and the parser now + records the difference. A closure stored in a field can be called as + `(obj.f)(x)`: field accesses carry their real source span, so the call parses. +- Nested `pub mod` and `pub` items inside a module parse and transpile. +- `@test("…")` transpiles to `#[doc = "…"] #[test]`, and `#[derive(Debug)]` is + accepted, as the 5.0 attribute grammar says. +- A function that returns a `let`-bound `[]` infers `-> Vec<…>`. +- `range(a, b).count()` (and `sum`/`min`/`max`) compile. A `main` that awaits + runs on a small in-program executor, with no tokio needed. +- **(behaviour)** The interpreter's `{:?}` prints what Rust prints for + numbers, strings, arrays, tuples, `Option` and `Result` (`[1, 2]`, not + `Array([Integer(1), …])`). It differs in two cases: struct fields print sorted + by name rather than in declaration order (DEBUGORDER-1), and chars print as + strings. `println("{:?}", x)` and `format!` substitute + like the macro. `format!` no longer quotes its string arguments. +- **(behaviour)** `compute_hash` returns MD5 (32 hex characters) again, as its + contract says, using an in-tree RFC 1321 implementation. In 5.0.0-beta.2 a + dependency cleanup had silently made it SHA-256. +- **(behaviour)** CLI routing is exhaustive, so no verb can print "not yet + implemented" and exit 0. `ruchy prove` now reaches the prover. + `ruchy infra …` rejects a missing path or a directory. `ruchy serve` returns + an error when it cannot bind its port, and `--watch` keeps serving, with a + warning, when the host has no inotify instance left. +- **(behaviour)** The interpreter has `Option`/`Result` methods again + (OPTMETHODS-1): `unwrap`, `expect`, `unwrap_or`, `unwrap_or_else`, + `unwrap_or_default`, `is_some`/`is_none`/`is_ok`/`is_err`, `map`, + `and_then`, `ok`, `ok_or`, `or`, `filter`. `Some(v)`, `Ok(v)` and a plain + non-nil value are present; `None`, `Err(e)` and nil are absent, so + `v.pop().unwrap()` yields the element. A plain value's own methods win. + `unwrap`/`expect` on an absent value is an error naming it, and so is + `unwrap_or_default`, whose default type is unknown at runtime. A plain + object has `get(key)`, returning the value or nil. Only the builtin + `Option`/`Result` variants take these methods first: a user enum's own + `Some`/`None` variant keeps its `impl` methods, which the interpreter now + dispatches on enum values. +- **(behaviour)** In-place array methods change their receiver, as in Rust + (FIELDPOP-1, ARRAYMUT-1). `v.sort()`, `reverse`, `append`, `extend`, + `insert`, `remove`, `clear`, `truncate`, `dedup`, `resize` and + `extend_from_slice` mutate a local array or a field (`self.items.pop()`) + and return what Rust returns: nil, the removed element for `remove`, and the + element or nil for `pop`. Before, `sort` and `reverse` returned a copy and + left the variable unchanged, a field's `push`/`pop` was lost, and `insert`, + `remove`, `clear` and `truncate` were unknown. A nested field + (`self.inner.items.push(x)`, `o.a.b.sort()`) is written back too. + `sorted`/`reversed` still + return a copy. `a.append(b)` does not empty `b`. +- A `let`-bound array literal that is later grown (`push`, `extend`, `insert`, + …) or annotated `Vec<…>` transpiles to `vec![…]`, and so does an array + literal for a `Vec<…>` struct field (LETVEC-1, VECLIT-1). Before, + `let mut v = [1, 2]; v.push(3)` failed rustc (E0599). The scan follows + scope: growth after a shadowing `let v = …`, or inside a closure or block + that re-binds `v`, does not count for the outer `v`. +- **(behaviour)** `a.append(b)` transpiles to `a.extend_from_slice(&b)`, so `b` + (a `Vec` or an array) stays usable afterwards, as in the interpreter. Before, + it transpiled to `a.push(b)`. +- A keyword can name a method or field after `.` or `?.` (`v.extend(x)`, + `o.type`), and a struct can declare it (`struct W { type: i32 }`). A Rust + reserved word is emitted as `r#type` (EXTENDKW-1, RESFIELD-1). A `let` + named `self`, `Self`, `super` or `crate` no longer panics the transpiler. + An enum struct variant may name a field with a keyword too (ENUMFIELDKW-1). +- An assignment through a field and index chain (`o.items[0].z = 5`) takes + effect in the interpreter (NESTASSIGN-1) and in compiled code + (IDXASSIGN-1). Before, the transpiler assigned to a `.clone()` of the + element, so the write was silently lost. Compound assignment, `&mut` and + mutating-method receivers on such a chain are emitted as places as well. +- A method or function whose last expression is a unit-returning call + (`self.items.push(x)`) infers no return type (UNITRET-1), and one ending in + `len()` or `count()` infers `-> usize` (LENRET-1). A `len()` or `count()` + that meets an `int` annotation, an `int` return tail or an `int` parameter + is cast (INTLEN-1). +- The interpreter accepts compound assignment to an index or field chain + (`v[1] += 5`, `o.items[0].z -= 1`) (IDXCOMPOUND-1), and in-place array + methods through an index chain (`t.rows[0].vals.push(9)`) are written back + (IDXPUSHWB-1). `count()` works on arrays and on `chars()`, `iter()` and + `bytes()` in the interpreter, and the transpiler no longer adds `.iter()` to + a receiver that is already an iterator (COUNTITER-1). +- **(behaviour)** A `print`/`println` whose first argument is a string literal + with any placeholder (`{:?}`, `{:>5}`, `{0}`, …) uses it as the format + string when compiled (PRINTLNFMT-1). Before, only `{}` was recognised, so + `println("{:?}", v)` printed the format string followed by the value. +- **(behaviour)** A compiled `println(s)` of a single string-typed variable + prints the text, not its quoted `{:?}` form (PRINTSTR-1). +- `format("…", args)` transpiles to `format!` (FORMATFN-1), and an explicit + `return v.len()` from an `int` function is cast like a tail (RETLEN-1). +- A string key works at any link of an index chain: `d["k"]` reads, `d["k"] = v` + assigns and `d["k"].push(x)` mutates the map, in the interpreter and in + compiled code (DICTIDX-1). +- **(behaviour)** The interpreter's `println`, `print` and `format` share one + formatting engine that follows Rust's `std::fmt` (FMTSPEC-1): width, fill, + alignment, sign, `#`, `0`, precision and positional and named arguments. + Three results change: a placeholder count that does not match the argument + count is an error, `{:.2}` on an integer prints the integer (`42`), and + `{:.2}` on a string truncates it. +- `eprintln` and `eprint` are builtins that write formatted text to stderr + (EPRINTLN-1). +- An array literal passed to a parameter annotated `Vec` becomes a `Vec` + in compiled code (ARRVECARG-1). +- A call to a unit-returning user function as the last statement of `main` + is no longer wrapped in `println!("{:?}", ..)`, so the compiled program does + not print `()` (MAINUNIT-1). +- A dict literal whose values are all ints, all floats or all bools keeps that + value type in compiled code instead of `BTreeMap`, so + `d["a"] = 5` and `d["a"] + d["b"]` compile; string and mixed dicts are + unchanged (DICTTYPE-1). +- **(behaviour)** The interpreter prints a float under `{}` (in `println`, + `format` and f-strings) as Rust's f64 Display does: `3`, not `3.0`; + `1000000000000000000000`, `-0`, `NaN`, `inf` (FLOATDISP-1). `{:?}` and a + bare `println(x)`, which compiles to `{:?}`, print Rust's Debug text + (`3.0`, `1e21`, `1e-7`), matching the compiled binary. +- `property-tests`, `fuzz` and `notebook` remove the compiled + `ruchy_temp_bin_*` binary on every return path, not only on success + (TMPLEAK-1). +- A single-argument `println` prints with `{}` only for a name that is a + string in the current scope: a for-loop variable, closure parameter, or + match / if-let / while-let binding that re-binds a string variable drops + its string record until the scope ends, and `replace` / `repeat` count as + string results only on a string receiver (`[1].repeat(2)` is a `Vec`). + Before, both printed a `Vec` with `{}` and failed rustc (PRINTSTRSCOPE-1). +- The interpreter evaluates each index expression of a compound assignment + (`v[next()] += 1`), a nested-index assignment (`m[f()][1] = 9`) or an + in-place array method on an element (`m[f()].push(x)`) once, as compiled + code does; it used to run it again to write the place back (IDXEVAL1-1). +- **(behaviour)** The interpreter treats the first argument of `println`, + `print`, `eprintln`, `eprint` and `format` as a format template only when + it is a string literal, as the transpiler does: `let s = "{}"; println(s, 1)` + prints `{} 1`, not `1` (FMTTEMPLATE-1). +- **(behaviour)** A format string that never uses one of its positional + arguments is an error in the interpreter, as rustc rejects it: + `println("{}", 1, 2)` and `println("{0} {0}", 1, 2)` fail with + "argument never used"; an explicit `{1}` counts as a use (FMTEXTRA-1). +- A nested array literal whose elements are grown through an index + (`m[0].push(9)`, `m[i].insert(0, x)`) is transpiled with `vec![..]` inner + literals, so `let mut m = [[1], [2]]; m[0].push(9)` compiles and prints + `[[1, 9], [2]]` instead of failing rustc E0599 (NESTARRVEC-1). +- A call to a let-bound closure whose body is unit, as the last statement + of main, is no longer printed: `let f = |n| println(n); f(7)` prints `7`, + not `7` then `()` (CLOSUREUNIT-1). +- **(behaviour)** A `for` loop binds its variable or pattern in a scope of + its own for each iteration: `let x = "a"; for x in [[1]] { }; println(x)` + prints `a`, not `[1]`, and `for (k, v) in pairs` binds `k` and `v` to the + tuple's fields instead of `k` to the whole tuple. Assignments to outer + variables in the body still persist (FORLEAK-1). +- Arrays have `repeat(n)`, as Rust slices do: `[1].repeat(2)` is `[1, 1]`; + a negative count is an error (ARRREPEAT-1). +- **(behaviour)** `format`, `println`, `print`, `eprintln` and `eprint` + called through a variable or parameter (`let g = format; g(t, 1)`) follow + the FMTTEMPLATE-1 rule: only a string literal first argument is a + template, so a non-literal one prints `{} 1` (FORMATVAL-1). +- An untyped function or closure parameter whose emitted type is `String` + or `&str` (a closure's from its call sites) prints with `{}`: + `fun p(s) { println(s) }; p("hi")` prints `hi`, not `"hi"` (PRINTPARAM-1). +- An array literal pushed or inserted into a list whose inner literals + became `vec![..]` (NESTARRVEC-1) is emitted as `vec![..]` too, so + `m[0].push(2); m.push([3])` compiles and prints `[[1, 2], [3]]` + (NESTPUSHLIT-1). +- Arrays have `clone`, `to_vec` and `to_owned`; tuples and strings have + `clone` and `to_owned`; plain objects have `clone`. A clone is + independent: writes to it leave the original unchanged (ARRCLONE-1). +- `ruchy run` prints a lone string literal passed to `println`/`print` + verbatim, as the compiled binary does: `println("{}")` prints `{}`, not a + format error, and `println("x{{")` prints `x{{`. `format("x{{")` is still + a template and returns `x{` (LONELIT-1). +- Compiled `println(x.replace(a, b))` prints as text for any receiver (the + two-argument `replace` is `str::replace`; `Option::replace` takes one), and + `println(x.repeat(n))` prints as text unless `x` is known to be a list: a + list literal (`[..]`, `[x; n]`, `vec![..]`, `vec![x; n]`), a binding to + one, or a binding or parameter annotated + `Vec`/`[T]`. A loop variable, field or call receiver no longer prints + quoted; a list receiver the transpiler cannot see (an untyped parameter + bound to a list) now needs an annotation. A plain loop variable over + `lines()`, `split_whitespace()`, `split()` or a list of string + literals prints as text. A `for`/`while` loop or `+=` as main's last + statement is not printed as `()`. Strings have `split_whitespace()` and + `vec![x; n]` evaluates under `ruchy run` (STRRECV-1). +- Mutation detection walks every expression kind, including macro arguments. +- Tests: + - they spawn the cargo-built binary; + - they write compiled output (`a.out`, `.rlib`) into temp dirs, never the repository; + - `ruchy serve` tests stop through a shutdown seam instead of serving forever; + - `release_hygiene` names a dirty tree; + - pmat-written files are gitignored. + ### Added — RHL-4, RHL-5, RHL-6, RHL-9: RHL compiles, tests, contracts and talks MCP (RHL-001, experimental) - **Lowering (RHL-4):** a checked `job` lowers to Ruchy source, following spec diff --git a/docs/roadmaps/roadmap.yaml b/docs/roadmaps/roadmap.yaml index c3bcfac9e..371a9569f 100644 --- a/docs/roadmaps/roadmap.yaml +++ b/docs/roadmaps/roadmap.yaml @@ -3688,13 +3688,15 @@ roadmap: github_issue: null item_type: task title: 'MAINUNIT-1: a unit-returning call as the last statement of main is transpiled as println!("{:?}", f()) and prints ()' - status: planned + status: completed priority: high assigned_to: null created: 2026-09-23T14:07:20Z - updated: 2026-09-23T14:07:20Z + updated: 2026-09-24T18:00:00Z spec: null - acceptance_criteria: [] + acceptance_criteria: + - 'Repro: fun hello() { println("hi") }; fun main() { let x = 1; hello() }' + - 'Acceptance: run and the compiled binary both print exactly hi' phases: [] subtasks: [] estimated_effort: null @@ -3982,6 +3984,908 @@ roadmap: - tests - filed-by:RHLGA-1 notes: null +- id: DEBUGORDER-1 + github_issue: null + item_type: task + title: 'DEBUGORDER-1: interpreter {:?} prints struct fields sorted by name; rustc prints declaration order (Value keeps no field order)' + status: planned + priority: medium + assigned_to: null + created: 2026-09-24T01:39:25Z + updated: 2026-09-24T01:39:25Z + spec: null + acceptance_criteria: [] + phases: [] + subtasks: [] + estimated_effort: null + labels: + - kind:code + - filed-by:RHLGA-1 + - release:v5.1.0 + notes: null +- id: SPAWNKW-1 + github_issue: null + item_type: task + title: 'SPAWNKW-1: a trait method named spawn does not parse because spawn is a lexer keyword (SPECIFICATION 7.1 uses fun spawn(self))' + status: planned + priority: medium + assigned_to: null + created: 2026-09-24T01:39:25Z + updated: 2026-09-24T01:39:25Z + spec: null + acceptance_criteria: [] + phases: [] + subtasks: [] + estimated_effort: null + labels: + - kind:code + - filed-by:RHLGA-1 + - release:v5.1.0 + notes: null +- id: POSTINC-1 + github_issue: null + item_type: task + title: 'POSTINC-1: self.count++ as the tail of a unit function emits a value (E0308)' + status: planned + priority: medium + assigned_to: null + created: 2026-09-24T01:39:26Z + updated: 2026-09-24T01:39:26Z + spec: null + acceptance_criteria: [] + phases: [] + subtasks: [] + estimated_effort: null + labels: + - kind:code + - filed-by:RHLGA-1 + - release:v5.1.0 + notes: null +- id: FIELDPOP-1 + github_issue: null + item_type: task + title: 'FIELDPOP-1: self.items.pop() inside a method returns the whole array instead of the popped element (interpreter)' + status: completed + priority: critical + assigned_to: null + created: 2026-09-24T05:31:58Z + updated: 2026-09-24T05:31:58Z + spec: null + acceptance_criteria: + - 'Repro: struct Stack { items: Vec } with fun pop_one(&mut self) -> i32 { self.items.pop().unwrap() }; ruchy run failed "Unknown array method: unwrap" (self.items.pop() returned the remaining array [1, 2]) and s.items.len() stayed 3; s.items.push(x) / o.items.pop() on a struct variable or object literal did not change the field either' + - 'Root cause: Interpreter::eval_method_call special-cased push/pop only for an identifier receiver and push only for an ObjectMut field; any other field receiver was evaluated to a copy and dispatched to eval_array_method, whose functional pop/push return the updated copy, and nothing wrote the copy back to the field' + - 'Fix: try_field_array_mutation (interpreter_methods_dispatch.rs) runs push/pop on a field rooted at an identifier (self, a struct/object/class variable), returns the element (nil when empty) or nil for push, and writes the array back through eval_field_assign (Struct rebinds, Object copies, ObjectMut/Class mutate in place)' + - 'tests/fieldpop_1_field_methods.rs: self/struct/object/class field push+pop, while-loop persistence, empty pop, field-vs-local parity for pop/push/sorted/reversed/drop/append/sort/reverse/len, differential through rustc; 3/3 hand mutants caught' + - 'Open: the exact repro still fails in the interpreter with "Unknown integer method: unwrap" because array pop returns the raw element (not Option) and the interpreter has no Option unwrap (Some(3).unwrap() fails: Method unwrap not found for type enum_variant)' + phases: [] + subtasks: [] + estimated_effort: null + labels: + - kind:code + - runtime + - filed-by:RHLGA-1 + notes: null +- id: VECLIT-1 + github_issue: null + item_type: task + title: 'VECLIT-1: an array literal assigned to a Vec field transpiles to an array, rustc E0308' + status: completed + priority: high + assigned_to: null + created: 2026-09-24T05:31:58Z + updated: 2026-09-24T05:31:58Z + spec: null + acceptance_criteria: + - 'Repro: struct Stack { items: Vec }; let s = Stack { items: [1, 2, 3] } transpiled to Stack { items: [1, 2, 3] } and rustc failed E0308 (expected Vec, found [{integer}; 3])' + - 'Root cause: transpile_struct_literal emitted every field value with transpile_expr and never consulted the recorded field type except for String; ExprKind::List emits a Rust array literal, which does not coerce to Vec' + - 'Fix: transpile_field_value (expressions_helpers/collections.rs) prefixes vec! when the value is a list literal and the field type recorded by the struct pass is Vec or Generic { base: "Vec" }; fixed-size [T; N] fields keep the array literal' + - 'tests/fieldpop_1_field_methods.rs test_veclit_1_*: rustc differential for Vec and Vec> fields plus an [i32; 2] field; hand mutant (Generic Vec not recognised) caught' + phases: [] + subtasks: [] + estimated_effort: null + labels: + - kind:code + - transpiler + - filed-by:RHLGA-1 + notes: null +- id: OPTMETHODS-1 + github_issue: null + item_type: task + title: 'OPTMETHODS-1: the interpreter has no Option/Result methods (Some(3).unwrap() fails) and map.get fails' + status: completed + priority: critical + assigned_to: null + created: 2026-09-24T08:00:00Z + updated: 2026-09-24T08:00:00Z + spec: null + acceptance_criteria: + - 'Repro: Some(3).unwrap() -> "Method unwrap not found for type enum_variant"; Ok(2).unwrap_or(0) and None.unwrap_or(5) fail the same way; {"a": 1}.get("a") -> "Object is missing __type marker"' + - 'Ruling (orchestrator, RHLGA-1): the interpreter keeps its nil-for-absent library results (pop/first/get return the element or nil); Option methods accept both representations: Some(v)/Ok(v) and a plain non-nil value act as present, None/Err(e)/nil as absent. So v.pop().unwrap() yields the element, as the transpiled Rust does' + - 'Methods: unwrap, expect, unwrap_or, unwrap_or_else, unwrap_or_default, is_some, is_none, is_ok, is_err, map, and_then, ok, ok_or, or, filter where applicable; unwrap/expect on absent is a runtime error naming the value' + - 'Acceptance: tests/optmethods_1_option_methods.rs, every method on Some/None/Ok/Err/plain/nil, plus self.items.pop().unwrap() in a struct method returning 3 then 2, differential through rustc' + phases: [] + subtasks: [] + estimated_effort: null + labels: + - kind:code + - runtime + - filed-by:RHLGA-1 +- id: ARRAYMUT-1 + github_issue: null + item_type: task + title: 'ARRAYMUT-1: in-place array methods do not mutate (v.sort() leaves v unsorted) and insert/remove/clear/truncate are unknown (interpreter)' + status: completed + priority: critical + assigned_to: null + created: 2026-09-24T08:00:00Z + updated: 2026-09-24T08:00:00Z + spec: null + acceptance_criteria: + - 'Repro: let mut v = [3,1,2]; v.sort(); println(v) prints [3, 1, 2]; v.reverse() likewise; v.insert(0, 9) -> "Unknown array method: insert"; remove, clear, truncate the same' + - 'Fix: sort, reverse, append, insert, remove, clear, truncate, dedup, extend mutate the receiver in place for a local identifier and for a field receiver (try_field_array_mutation path), returning what Rust returns (unit, or the removed element for remove); sorted/reversed stay functional' + - 'Acceptance: tests/arraymut_1_in_place.rs, each method on a local and on self.field, differential through rustc' + phases: [] + subtasks: [] + estimated_effort: null + labels: + - kind:code + - runtime + - filed-by:RHLGA-1 +- id: EXTENDKW-1 + github_issue: null + item_type: task + title: 'EXTENDKW-1: v.extend([3]) is a parse error "Expected method name, tuple index, or await after ."' + status: completed + priority: critical + assigned_to: null + created: 2026-09-24T08:00:00Z + updated: 2026-09-24T08:00:00Z + spec: null + acceptance_criteria: + - 'Repro: let mut v = [1,2]; v.extend([3]) -> Syntax error: Expected method name, tuple index, or await after .' + - 'Fix: a keyword token after . in method position is accepted as a method name (extend and any other contextual keyword), without changing the keyword elsewhere' + - 'Acceptance: tests/extendkw_1_keyword_methods.rs parses and transpiles v.extend(x) and each keyword that can follow . as a method name' + phases: [] + subtasks: [] + estimated_effort: null + labels: + - kind:code + - runtime + - filed-by:RHLGA-1 +- id: LETVEC-1 + github_issue: null + item_type: task + title: 'LETVEC-1: let mut v = [1, 2]; v.push(3) transpiles to a fixed-size Rust array and fails rustc E0599' + status: completed + priority: critical + assigned_to: null + created: 2026-09-24T08:30:00Z + updated: 2026-09-24T08:30:00Z + spec: null + acceptance_criteria: + - 'Repro: fun main() { let mut v = [1, 2]; v.push(3); println(v.len()) } -> transpile emits let mut v = [1, 2]; ruchy compile fails E0599 no method push for [{integer}; 2]. Same with a Vec annotation. Pre-existing in 4.2.1' + - 'Fix: an array literal bound by let is emitted as vec![...] when the binding is annotated Vec<...> or the binding is later used with a Vec-only method (push, pop, extend, insert, remove, clear, truncate, append, dedup, retain, drain, resize) or passed where the interpreter semantics require growth; otherwise unchanged' + - 'Acceptance: tests/letvec_1_growable_locals.rs, transpile + rustc-compile + run for each Vec-only method on an unannotated and an annotated local, top-level script and fun main, and a fixed array that is never grown stays an array' + phases: [] + subtasks: [] + estimated_effort: null + labels: + - kind:code + - transpiler + - filed-by:RHLGA-1 +- id: RESFIELD-1 + github_issue: null + item_type: task + title: 'RESFIELD-1: a field named by a Rust reserved word (o.match, o.type) transpiles to invalid Rust' + status: completed + priority: critical + assigned_to: null + created: 2026-09-24T08:30:00Z + updated: 2026-09-24T08:30:00Z + spec: null + acceptance_criteria: + - 'Repro (after EXTENDKW-1): o.type passes ruchy check, ruchy transpile fails "Failed to parse generated tokens as Rust syntax"; transpile_field_access in expressions_helpers/field_access.rs builds the name with format_ident! instead of safe_ident' + - 'Fix: field access, struct field declarations and struct literal field names use safe_ident (r#kw)' + - 'Acceptance: tests/extendkw_1_keyword_methods.rs field test asserts transpile + rustc for every reserved-word field name' + phases: [] + subtasks: [] + estimated_effort: null + labels: + - kind:code + - transpiler + - filed-by:RHLGA-1 +- id: NESTASSIGN-1 + github_issue: null + item_type: task + title: 'NESTASSIGN-1: nested field assignment through an index chain (o.items[0].z = 5) is lost in the interpreter' + status: completed + priority: critical + assigned_to: null + created: 2026-09-24T10:30:00Z + updated: 2026-09-24T10:30:00Z + spec: null + acceptance_criteria: + - 'Fix: eval_field_assign accepts an Identifier/FieldAccess/IndexAccess chain and writes back through it' + - 'Acceptance: tests/nestassign_1_nested_field_assign.rs' + phases: [] + subtasks: [] + estimated_effort: null + labels: + - kind:code + - runtime + - filed-by:RHLGA-1 + notes: null +- id: UNITRET-1 + github_issue: null + item_type: task + title: 'UNITRET-1: a method whose tail is a unit-returning call (v.push(x)) infers a non-unit return type' + status: completed + priority: critical + assigned_to: null + created: 2026-09-24T10:30:00Z + updated: 2026-09-24T10:30:00Z + spec: null + acceptance_criteria: + - 'Fix: function_analysis is_unit_method; unit tails infer no return type' + - 'Acceptance: tests/unitret_1_unit_body_return.rs' + phases: [] + subtasks: [] + estimated_effort: null + labels: + - kind:code + - transpiler + - filed-by:RHLGA-1 + notes: null +- id: ENUMFIELDKW-1 + github_issue: null + item_type: task + title: 'ENUMFIELDKW-1: a keyword naming an enum struct-variant field fails to parse / transpile' + status: completed + priority: critical + assigned_to: null + created: 2026-09-24T10:30:00Z + updated: 2026-09-24T10:30:00Z + spec: null + acceptance_criteria: + - 'Fix: parser enums.rs uses the member-name table; transpile_enum emits safe_ident' + - 'Acceptance: tests/enumfieldkw_1.rs' + phases: [] + subtasks: [] + estimated_effort: null + labels: + - kind:code + - parser + - filed-by:RHLGA-1 + notes: null +- id: IDXASSIGN-1 + github_issue: null + item_type: task + title: 'IDXASSIGN-1: o.items[0].z = 5 transpiles to o.items[0 as usize].clone().z = 5: the write is silently lost' + status: completed + priority: critical + assigned_to: null + created: 2026-09-24T10:30:00Z + updated: 2026-09-24T10:30:00Z + spec: null + acceptance_criteria: + - 'Repro: compiled binary prints the old value' + - 'Fix: Transpiler::transpile_place emits place chains without clone for =, compound assignment, &mut and mutating-method receivers' + - 'Acceptance: tests/idxassign_1_place_targets.rs (8 rustc differential tests)' + phases: [] + subtasks: [] + estimated_effort: null + labels: + - kind:code + - transpiler + - filed-by:RHLGA-1 + notes: null +- id: LENRET-1 + github_issue: null + item_type: task + title: 'LENRET-1: fun f(xs) { xs.len() } infers -> i32 but len() is usize (E0308)' + status: completed + priority: critical + assigned_to: null + created: 2026-09-24T10:30:00Z + updated: 2026-09-24T10:30:00Z + spec: null + acceptance_criteria: + - 'Fix: a zero-arg len()/count() tail infers -> usize' + - 'Acceptance: tests/unitret_1_unit_body_return.rs test_lenret_1_*' + phases: [] + subtasks: [] + estimated_effort: null + labels: + - kind:code + - transpiler + - filed-by:RHLGA-1 + notes: null +- id: PRINTLNFMT-1 + github_issue: null + item_type: task + title: 'PRINTLNFMT-1: println("{:?}", v) with one argument compiles to println!("{} {:?}", "{:?}", v) and prints the format string' + status: completed + priority: critical + assigned_to: null + created: 2026-09-24T10:30:00Z + updated: 2026-09-24T10:30:00Z + spec: null + acceptance_criteria: + - 'Repro: println("{:?}", v) and println("v={:?}", v) transpile wrong; println("{}", x) and two-argument forms are right' + - 'Acceptance: rustc differential test on every placeholder form with one and several arguments' + phases: [] + subtasks: [] + estimated_effort: null + labels: + - kind:code + - transpiler + - filed-by:RHLGA-1 + notes: null +- id: INTLEN-1 + github_issue: null + item_type: task + title: 'INTLEN-1: let n: int = v.len() (and -> int returning len()) emits i64 = usize without a cast (E0308)' + status: completed + priority: critical + assigned_to: null + created: 2026-09-24T10:30:00Z + updated: 2026-09-24T10:30:00Z + spec: null + acceptance_criteria: + - 'Acceptance: rustc differential test for let/return/argument positions annotated int receiving len()/count()' + phases: [] + subtasks: [] + estimated_effort: null + labels: + - kind:code + - transpiler + - filed-by:RHLGA-1 + notes: null +- id: COUNTITER-1 + github_issue: null + item_type: task + title: 'COUNTITER-1: count() gets .iter() appended even on an iterator (s.chars().count() -> s.chars().iter().count(), E0599); the interpreter has no array count' + status: completed + priority: critical + assigned_to: null + created: 2026-09-24T10:30:00Z + updated: 2026-09-24T10:30:00Z + spec: null + acceptance_criteria: + - 'Acceptance: transpile + rustc + interpreter tests for s.chars().count(), xs.iter().count(), v.count() where the interpreter and rustc agree' + phases: [] + subtasks: [] + estimated_effort: null + labels: + - kind:code + - transpiler + - filed-by:RHLGA-1 + notes: null +- id: IDXCOMPOUND-1 + github_issue: null + item_type: task + title: 'IDXCOMPOUND-1: interpreter: v[1] += 5 fails with Invalid compound assignment target' + status: completed + priority: critical + assigned_to: null + created: 2026-09-24T10:30:00Z + updated: 2026-09-24T10:30:00Z + spec: null + acceptance_criteria: + - 'Acceptance: compound assignment to index and field-of-index targets agrees with the compiled binary' + phases: [] + subtasks: [] + estimated_effort: null + labels: + - kind:code + - runtime + - filed-by:RHLGA-1 + notes: null +- id: IDXPUSHWB-1 + github_issue: null + item_type: task + title: 'IDXPUSHWB-1: interpreter: t.rows[0].vals.push(9) is not written back to t (prints [1]; compiled prints [1, 9])' + status: completed + priority: critical + assigned_to: null + created: 2026-09-24T10:30:00Z + updated: 2026-09-24T10:30:00Z + spec: null + acceptance_criteria: + - 'Acceptance: in-place array methods through an index chain are written back, interpreter agrees with rustc' + phases: [] + subtasks: [] + estimated_effort: null + labels: + - kind:code + - runtime + - filed-by:RHLGA-1 + notes: null +- id: PRINTSTR-1 + github_issue: null + item_type: task + title: 'PRINTSTR-1: println(s) with a String/&str variable compiles to println!("{:?}", s) and prints quotes' + status: completed + priority: critical + assigned_to: null + created: 2026-09-24T11:30:00Z + updated: 2026-09-24T14:00:00Z + spec: null + acceptance_criteria: + - 'Repro: let t = s.to_string(); println(t) prints "hi" compiled, hi interpreted' + - 'Acceptance: rustc differential for println/print of str, String, format! results and numbers' + phases: [] + subtasks: [] + estimated_effort: null + labels: + - kind:code + - transpiler + - filed-by:RHLGA-1 + notes: null +- id: FMTSPEC-1 + github_issue: null + item_type: task + title: 'FMTSPEC-1: interpreter println/format ignore width, precision and positional specs ({:>5}, {:.2}, {0})' + status: completed + priority: critical + assigned_to: null + created: 2026-09-24T11:30:00Z + updated: 2026-09-24T14:00:00Z + spec: null + acceptance_criteria: + - 'Repro: println("[{:>5}] [{:.2}]", 7, 3.14159) prints the placeholders literally' + - 'Acceptance: interpreter output equals rustc for fill/align/width/precision/positional/named specs' + phases: [] + subtasks: [] + estimated_effort: null + labels: + - kind:code + - runtime + - filed-by:RHLGA-1 + notes: null +- id: EPRINTLN-1 + github_issue: null + item_type: task + title: 'EPRINTLN-1: interpreter: eprintln/eprint are undefined functions' + status: completed + priority: critical + assigned_to: null + created: 2026-09-24T11:30:00Z + updated: 2026-09-24T14:00:00Z + spec: null + acceptance_criteria: + - 'Acceptance: eprintln/eprint write to stderr in the interpreter, as compiled' + phases: [] + subtasks: [] + estimated_effort: null + labels: + - kind:code + - runtime + - filed-by:RHLGA-1 + notes: null +- id: FORMATFN-1 + github_issue: null + item_type: task + title: 'FORMATFN-1: format("{:?}", v) function form fails: rustc E0423 compiled, Unknown builtin __builtin_format__ interpreted' + status: completed + priority: critical + assigned_to: null + created: 2026-09-24T11:30:00Z + updated: 2026-09-24T14:00:00Z + spec: null + acceptance_criteria: + - 'Acceptance: format(...) function form equals format!(...) in both backends' + phases: [] + subtasks: [] + estimated_effort: null + labels: + - kind:code + - transpiler + - filed-by:RHLGA-1 + notes: null +- id: RETLEN-1 + github_issue: null + item_type: task + title: 'RETLEN-1: return v.len() in a -> int function is not cast (E0308); INTLEN-1 covered only tails, lets and arguments' + status: completed + priority: critical + assigned_to: null + created: 2026-09-24T11:30:00Z + updated: 2026-09-24T14:00:00Z + spec: null + acceptance_criteria: + - 'Acceptance: rustc test for explicit return of len()/count() in int-returning functions' + phases: [] + subtasks: [] + estimated_effort: null + labels: + - kind:code + - transpiler + - filed-by:RHLGA-1 + notes: null +- id: DICTIDX-1 + github_issue: null + item_type: task + title: 'DICTIDX-1: string-keyed dict index: transpiler emits d["k" as usize]; interpreter d["k"].push(2) errors Array index must be an integer' + status: completed + priority: critical + assigned_to: null + created: 2026-09-24T11:30:00Z + updated: 2026-09-24T14:00:00Z + spec: null + acceptance_criteria: + - 'Repro: let mut d = {"k": [1]}; d["k"].push(2); println("{:?}", d["k"])' + - 'Acceptance: dict read, assignment and in-place method through a string key agree between interpreter and rustc' + phases: [] + subtasks: [] + estimated_effort: null + labels: + - kind:code + - transpiler + - filed-by:RHLGA-1 + notes: null +- id: ARRVECARG-1 + github_issue: null + item_type: task + title: 'ARRVECARG-1: array literal passed to a Vec parameter transpiles without .to_vec() (E0308)' + status: completed + priority: high + assigned_to: null + created: 2026-09-24T14:00:00Z + updated: 2026-09-24T18:00:00Z + spec: null + acceptance_criteria: + - 'Repro: fun n(v: Vec) -> i32 { v[0] }; fun main() { println("{}", n([1, 2, 3])) }' + - 'Acceptance: run prints 1; compile succeeds and the binary prints 1' + phases: [] + subtasks: [] + estimated_effort: null + labels: + - kind:code + - transpiler +- id: DICTTYPE-1 + github_issue: null + item_type: task + title: 'DICTTYPE-1: dict literals with non-list values transpile as BTreeMap; d["k"] = 5 and d["a"] + d["b"] fail E0308' + status: completed + priority: critical + assigned_to: null + created: 2026-09-24T14:00:00Z + updated: 2026-09-24T18:00:00Z + spec: null + acceptance_criteria: + - 'Repro: let mut d = {"a": 1, "b": 2}; d["a"] = 5; println("{}", d["a"] + d["b"])' + - 'Acceptance: run and the compiled binary both print 7' + phases: [] + subtasks: [] + estimated_effort: null + labels: + - kind:code + - transpiler +- id: FLOATDISP-1 + github_issue: null + item_type: task + title: 'FLOATDISP-1: interpreter prints an integral float with {} as 3.0 where the compiled binary prints 3' + status: completed + priority: high + assigned_to: null + created: 2026-09-24T14:00:00Z + updated: 2026-09-24T18:00:00Z + spec: null + acceptance_criteria: + - 'Repro: let x = 3.0; println("{} {}", x, 2.5); println(x)' + - 'Acceptance: run and the compiled binary print identical text for {} and bare println of a float' + phases: [] + subtasks: [] + estimated_effort: null + labels: + - kind:code + - runtime +- id: TMPLEAK-1 + github_issue: null + item_type: task + title: 'TMPLEAK-1: ruchy_temp_bin_* binaries leak in the temp dir: notebook and property-tests handlers remove them only on the success path' + status: completed + priority: medium + assigned_to: null + created: 2026-09-24T14:00:00Z + updated: 2026-09-24T18:00:00Z + spec: null + acceptance_criteria: + - 'Repro: src/bin/handlers/property_tests_handler.rs:316-317 and notebook_handler.rs:32 return with ? before remove_file; 15 files (65M) found in /tmp' + - 'Acceptance: the temp binary is removed on every return path (RAII guard); a test asserts no ruchy_temp_bin_ file remains after a failing run' + phases: [] + subtasks: [] + estimated_effort: null + labels: + - kind:code + - cli +- id: IDXEVAL1-1 + github_issue: null + item_type: task + title: 'IDXEVAL1-1: interpreter evaluates the index expression of a compound or nested-index assignment twice (read + write_back); compiled code evaluates it once' + status: completed + priority: high + assigned_to: null + created: 2026-09-24T13:00:00Z + updated: 2026-09-24T18:00:00Z + spec: null + acceptance_criteria: + - 'Repro: v[next()] += 1 or m[f()][j] = x with a side-effecting index' + - 'Acceptance: the index expression is evaluated exactly once in the interpreter; run and compiled output agree' + phases: [] + subtasks: [] + estimated_effort: null + labels: + - kind:code + - runtime +- id: FMTTEMPLATE-1 + github_issue: null + item_type: task + title: 'FMTTEMPLATE-1: interpreter println/print with a non-literal first argument and 2+ args treats it as a format template; compiled code treats only a string literal as one' + status: completed + priority: high + assigned_to: null + created: 2026-09-24T13:00:00Z + updated: 2026-09-24T18:00:00Z + spec: null + acceptance_criteria: + - 'Repro: let s = "{}"; println(s, 1)' + - 'Acceptance: run and the compiled binary print identical text' + phases: [] + subtasks: [] + estimated_effort: null + labels: + - kind:code + - runtime +- id: FMTEXTRA-1 + github_issue: null + item_type: task + title: 'FMTEXTRA-1: interpreter format engine silently ignores extra arguments; rustc rejects them, CHANGELOG says a count mismatch is an error' + status: completed + priority: medium + assigned_to: null + created: 2026-09-24T13:00:00Z + updated: 2026-09-24T18:00:00Z + spec: null + acceptance_criteria: + - 'Repro: println("{}", 1, 2)' + - 'Acceptance: the interpreter reports an argument-count error for extra arguments, as CHANGELOG FMTSPEC-1 states' + phases: [] + subtasks: [] + estimated_effort: null + labels: + - kind:code + - runtime +- id: PRINTSTRSCOPE-1 + github_issue: null + item_type: task + title: 'PRINTSTRSCOPE-1: PRINTSTR-1 string tracking is scope-unaware and STRING_RESULT_METHODS matches any receiver, so a shadowing for/closure/match binding or v.repeat(n) on a Vec emits {} and fails rustc' + status: completed + priority: high + assigned_to: null + created: 2026-09-24T13:00:00Z + updated: 2026-09-24T16:00:00Z + spec: null + acceptance_criteria: + - 'Repro: let x = "a"; for x in [[1]] { println(x) } and let v = [1]; println(v.repeat(2))' + - 'Acceptance: shadowed or non-string receivers print with {:?} and compile; string cases keep {}' + phases: [] + subtasks: [] + estimated_effort: null + labels: + - kind:code + - transpiler +- id: FORLEAK-1 + github_issue: null + item_type: task + title: 'FORLEAK-1: interpreter for-loop variable leaks into the enclosing scope and overwrites a same-named outer binding' + status: completed + priority: critical + assigned_to: null + created: 2026-09-24T14:30:00Z + updated: 2026-09-24T18:00:00Z + spec: null + acceptance_criteria: + - 'Repro: let x = "a"; for x in [[1]] { }; println(x) prints [1]' + - 'Acceptance: after the loop the outer x is unchanged ("a"); run matches the compiled binary' + phases: [] + subtasks: [] + estimated_effort: null + labels: + - kind:code + - runtime +- id: ARRREPEAT-1 + github_issue: null + item_type: task + title: 'ARRREPEAT-1: interpreter arrays have no repeat method (Unknown array method: repeat); Rust slices have repeat' + status: completed + priority: medium + assigned_to: null + created: 2026-09-24T14:30:00Z + updated: 2026-09-24T18:00:00Z + spec: null + acceptance_criteria: + - 'Repro: let v = [1]; println("{:?}", v.repeat(2))' + - 'Acceptance: run prints [1, 1] like the compiled binary' + phases: [] + subtasks: [] + estimated_effort: null + labels: + - kind:code + - runtime +- id: NESTARRVEC-1 + github_issue: null + item_type: task + title: 'NESTARRVEC-1: a nested array literal transpiles to a fixed-size array, so m[i].push(x) fails rustc E0599' + status: completed + priority: high + assigned_to: null + created: 2026-09-24T14:30:00Z + updated: 2026-09-24T18:00:00Z + spec: null + acceptance_criteria: + - 'Repro: let mut m = [[1], [2]]; m[0].push(9); println("{:?}", m)' + - 'Acceptance: compiled prints [[1, 9], [2]] like ruchy run' + phases: [] + subtasks: [] + estimated_effort: null + labels: + - kind:code + - transpiler +- id: CLOSUREUNIT-1 + github_issue: null + item_type: task + title: 'CLOSUREUNIT-1: a unit-returning closure call as the last statement of main is printed as () (MAINUNIT-1 covers user functions only)' + status: completed + priority: high + assigned_to: null + created: 2026-09-24T14:30:00Z + updated: 2026-09-24T18:00:00Z + spec: null + acceptance_criteria: + - 'Repro: let f = |s| println(s); f("hi") as the last statement of main' + - 'Acceptance: compiled prints only hi' + phases: [] + subtasks: [] + estimated_effort: null + labels: + - kind:code + - transpiler +- id: FORMATVAL-1 + github_issue: null + item_type: task + title: 'FORMATVAL-1: the format builtin called as a value (eval_builtin.rs:318) still treats a non-literal first argument as a template when args.len() > 1' + status: completed + priority: medium + assigned_to: null + created: 2026-09-24T14:30:00Z + updated: 2026-09-24T18:00:00Z + spec: null + acceptance_criteria: + - 'Repro: let t = "{}"; let g = format; println(g(t, 1))' + - 'Acceptance: run agrees with compiled / FMTTEMPLATE-1 rule' + phases: [] + subtasks: [] + estimated_effort: null + labels: + - kind:code + - runtime +- id: PRINTPARAM-1 + github_issue: null + item_type: task + title: 'PRINTPARAM-1: an untyped function or closure parameter inferred as String is printed by println with {:?}, so the compiled program prints "hi" with quotes' + status: completed + priority: high + assigned_to: null + created: 2026-09-24T16:00:00Z + updated: 2026-09-24T20:00:00Z + spec: null + acceptance_criteria: + - 'Repro: fun p(s) { println(s) }; p("hi") and let f = |s| println(s); f("hi")' + - 'Acceptance: compiled prints hi without quotes, like ruchy run' + phases: [] + subtasks: [] + estimated_effort: null + labels: + - kind:code + - transpiler +- id: ARRCLONE-1 + github_issue: null + item_type: task + title: 'ARRCLONE-1: interpreter arrays have no clone method (Unknown array method: clone)' + status: completed + priority: medium + assigned_to: null + created: 2026-09-24T16:00:00Z + updated: 2026-09-24T18:00:00Z + spec: null + acceptance_criteria: + - 'Repro: let a = [1]; let b = a.clone(); println("{:?}", b)' + - 'Acceptance: run prints [1] like the compiled binary' + phases: [] + subtasks: [] + estimated_effort: null + labels: + - kind:code + - runtime +- id: NESTPUSHLIT-1 + github_issue: null + item_type: task + title: 'NESTPUSHLIT-1: pushing an array literal into a list whose inner elements became vec! (NESTARRVEC-1) fails rustc E0308' + status: completed + priority: medium + assigned_to: null + created: 2026-09-24T16:00:00Z + updated: 2026-09-24T20:00:00Z + spec: null + acceptance_criteria: + - 'Repro: let mut m = [[1]]; m[0].push(2); m.push([3]); println("{:?}", m)' + - 'Acceptance: compiled prints [[1, 2], [3]] like ruchy run' + phases: [] + subtasks: [] + estimated_effort: null + labels: + - kind:code + - transpiler +- id: LONELIT-1 + github_issue: null + item_type: task + title: 'LONELIT-1: interpreter println/print parse a lone string literal as a format template; the compiled binary prints it verbatim' + status: completed + priority: high + assigned_to: null + created: 2026-09-24T19:00:00Z + updated: 2026-09-24T19:30:00Z + spec: null + acceptance_criteria: + - 'Repro: println("{}") is a runtime format error under ruchy run; the compiled binary prints {} (found by the G2b final quorum, second sonnet seat)' + - 'Acceptance: tests/g2b_runfix_6.rs test_lonelit_1_* compare ruchy run with the compiled binary' + phases: [] + subtasks: [] + estimated_effort: null + labels: + - kind:code + - runtime +- id: STRRECV-1 + github_issue: null + item_type: task + title: 'STRRECV-1: compiled println of replace/repeat on an untracked string receiver prints quoted ({:?}); ruchy run prints text' + status: completed + priority: high + assigned_to: null + created: 2026-09-24T20:00:00Z + updated: 2026-09-24T20:30:00Z + spec: null + acceptance_criteria: + - 'Repro: for line in text.lines() { println(line.replace("a","b")) } prints "bb" quoted when compiled (found by G2b round-2 quorum, sonnet seat); a trailing for loop in main printed ()' + - 'Acceptance: tests/g2b_transfix_6.rs test_strrecv_1_* compare compiled stdout with ruchy run' + phases: [] + subtasks: [] + estimated_effort: null + labels: + - kind:code + - transpiler +- id: DEADDISPATCH-1 + github_issue: null + item_type: task + title: 'DEADDISPATCH-1: duplicate string/generic method dispatch (src/runtime/eval_string.rs, eval_generic_method in eval_method_dispatch.rs) is unreachable and drifts from the live copies' + status: planned + priority: medium + assigned_to: null + created: 2026-09-24T21:00:00Z + updated: 2026-09-24T21:00:00Z + spec: null + acceptance_criteria: + - 'Found by the ARRCLONE-1 worker and seen again in STRRECV-1: split_whitespace was added to eval_string_methods.rs only' + - 'Acceptance: one dispatch per receiver kind; pmat query --literal "Unknown zero-argument string method" returns one site' + phases: [] + subtasks: [] + estimated_effort: null + labels: + - kind:code + - runtime + - milestone:backlog - id: PMAT-140 github_issue: null item_type: task diff --git a/src/backend/compiler.rs b/src/backend/compiler.rs index 5912f70fc..b30e6aac3 100644 --- a/src/backend/compiler.rs +++ b/src/backend/compiler.rs @@ -752,8 +752,9 @@ mod tests { println("Hello from compiled Ruchy!"); } "#; + let dir = tempfile::TempDir::new().expect("temp dir"); let options = CompileOptions { - output: PathBuf::from("/tmp/test_ruchy_binary"), + output: dir.path().join("out"), ..Default::default() }; // This might fail if the transpiler doesn't support the syntax yet @@ -1185,8 +1186,9 @@ mod tests { ]; for source in complex_sources { + let dir = tempfile::TempDir::new().expect("temp dir"); let options = CompileOptions { - output: PathBuf::from("/tmp/complex_test"), + output: dir.path().join("out"), ..Default::default() }; @@ -1391,10 +1393,17 @@ mod property_tests_compiler { #[test] fn test_compile_source_to_binary_never_panics(input: String) { // Limit input size to avoid timeout - let _input = if input.len() > 100 { &input[..100] } else { &input[..] }; + let input: String = input.chars().take(100).collect(); + // COMPILERACE-1: a random input can be a valid program (for example + // an empty one), so the binary goes into a temp dir, never a.out in + // the crate root. + let dir = tempfile::TempDir::new().expect("operation should succeed in test"); + let options = CompileOptions { + output: dir.path().join("out"), + ..Default::default() + }; // Function should not panic on any input, even invalid syntax let result = std::panic::catch_unwind(|| { - let options = CompileOptions::default(); let _ = compile_source_to_binary(&input, &options); }); // Assert that no panic occurred (Result can be Ok or Err, but no panic) diff --git a/src/backend/transpiler/ast_analysis.rs b/src/backend/transpiler/ast_analysis.rs index 66e970bc7..fe7f5ae8b 100644 --- a/src/backend/transpiler/ast_analysis.rs +++ b/src/backend/transpiler/ast_analysis.rs @@ -219,8 +219,16 @@ impl Transpiler { pub fn collect_signatures_from_expr(&mut self, expr: &Expr) { match &expr.kind { ExprKind::Function { - name, params, body, .. + name, + params, + body, + return_type, + .. } => { + // MAINUNIT-1: remember functions whose calls yield `()` + if return_type.is_none() && super::function_analysis::is_void_expression(body) { + self.unit_functions.insert(name.clone()); + } let param_types: Vec = params .iter() .map(|param| Self::type_to_string(¶m.ty)) diff --git a/src/backend/transpiler/bindings.rs b/src/backend/transpiler/bindings.rs index dcd984d1f..5291764b9 100644 --- a/src/backend/transpiler/bindings.rs +++ b/src/backend/transpiler/bindings.rs @@ -13,7 +13,7 @@ use super::Transpiler; use crate::frontend::ast::{Expr, ExprKind, Literal, Pattern, Type, TypeKind}; use anyhow::{bail, Result}; use proc_macro2::TokenStream; -use quote::{format_ident, quote}; +use quote::quote; impl Transpiler { /// Helper: Generate let binding statement with mutability and optional Vec type hint @@ -69,12 +69,13 @@ impl Transpiler { is_mutable: bool, ) -> Result { // Handle Rust reserved keywords by prefixing with r# - let safe_name = if Self::is_rust_reserved_keyword(name) { - format!("r#{name}") - } else { - name.to_string() - }; - let name_ident = format_ident!("{}", safe_name); + // RHLGA-1 F7: `safe_ident` never r#-escapes `self`/`Self`/`super`/`crate`. + let name_ident = Self::safe_ident(name); + self.track_string_binding(name, value, None); + // NESTARRVEC-1: inner array literals of a binding whose elements grow + let inner_vec_value = elem_grown_value(name, value, body); + let value = inner_vec_value.as_ref().unwrap_or(value); + self.track_inner_vec_binding(name, value); // Auto-detect mutability let effective_mutability = is_mutable @@ -85,8 +86,9 @@ impl Transpiler { let (value_tokens, needs_vec_type_hint) = match &value.kind { ExprKind::Literal(Literal::String(s)) => (quote! { #s.to_string() }, false), ExprKind::List(items) if items.is_empty() => (self.transpile_expr(value)?, true), - _ => (self.transpile_expr(value)?, false), + _ => (self.transpile_let_value(name, value)?, false), }; + let value_tokens = Self::growable_list_tokens(name, value, None, body, value_tokens); // HOTFIX: If body is Unit, this is a top-level let statement without scoping if matches!(body.kind, ExprKind::Literal(Literal::Unit)) { @@ -264,12 +266,10 @@ impl Transpiler { is_mutable: bool, is_const: bool, ) -> Result { - let safe_name = if Self::is_rust_reserved_keyword(name) { - format!("r#{name}") - } else { - name.to_string() - }; - let name_ident = format_ident!("{}", safe_name); + // RHLGA-1 F7: `safe_ident` never r#-escapes `self`/`Self`/`super`/`crate`. + let name_ident = Self::safe_ident(name); + self.track_string_binding(name, value, type_annotation); + self.track_inner_vec_binding(name, value); // PARSER-073: Generate const/let keyword based on const attribute let is_mutable_var = is_mutable @@ -287,6 +287,12 @@ impl Transpiler { // Handle value tokens and type hints let (value_tokens, needs_vec_type_hint) = self.process_let_value_with_type(name, value, type_annotation, is_mutable_var)?; + let value_tokens = if is_const { + value_tokens + } else { + Self::growable_list_tokens(name, value, type_annotation, body, value_tokens) + }; + let value_tokens = Self::cast_usize_to_annotation(value, type_annotation, value_tokens); // Generate type annotation let type_tokens = self.generate_type_tokens(type_annotation, needs_vec_type_hint)?; @@ -307,6 +313,104 @@ impl Transpiler { } } + /// INTLEN-1: `let n: int = v.len()` casts the `usize` value to the + /// annotated integer type. (complexity: 2) + fn cast_usize_to_annotation( + value: &Expr, + type_annotation: Option<&Type>, + tokens: TokenStream, + ) -> TokenStream { + match type_annotation { + Some(ty) => super::return_type_helpers::cast_usize_tokens( + value, + &Self::type_to_string(ty), + tokens, + ), + None => tokens, + } + } + + /// LETVEC-1: a non-empty array literal bound by `let` becomes `vec![..]` + /// when the binding is annotated `Vec<..>` or is grown later in `body` + /// (the receiver of a Vec-only method). Otherwise `tokens` is returned + /// unchanged, so a never-grown literal stays a fixed-size array. + fn growable_list_tokens( + name: &str, + value: &Expr, + type_annotation: Option<&Type>, + body: &Expr, + tokens: TokenStream, + ) -> TokenStream { + let is_literal = matches!(&value.kind, ExprKind::List(items) if !items.is_empty()); + let text = tokens.to_string(); + let is_array_tokens = text.starts_with('[') && text.ends_with(']'); + let needs_vec = type_annotation.is_some_and(is_vec_annotation) + || super::mutation_detection::is_grown_as_vec(name, body); + if is_literal && is_array_tokens && needs_vec { + quote! { vec! #tokens } + } else { + tokens + } + } + + /// PRINTPARAM-1: the value of `let name = value`; a closure value knows + /// the name it is called by. (complexity: 2) + fn transpile_let_value(&self, name: &str, value: &Expr) -> Result { + match &value.kind { + ExprKind::Lambda { params, body } => { + self.transpile_named_lambda(Some(name), params, body) + } + _ => self.transpile_expr(value), + } + } + + /// NESTPUSHLIT-1: remember whether `name` is bound to a list literal + /// whose inner literals are `vec![..]` (the NESTARRVEC-1 rewrite); any + /// other binding of `name` clears the record. (complexity: 2) + fn track_inner_vec_binding(&self, name: &str, value: &Expr) { + let mut lists = self.inner_vec_lists.borrow_mut(); + if has_vec_inner_literals(value) { + lists.insert(name.to_string()); + } else { + lists.remove(name); + } + } + + /// NESTPUSHLIT-1: the arguments of `object.push(x)` / `object.insert(i, x)` + /// with an array literal `x` as `vec![..]`, when `object` is a binding + /// whose inner literals are `vec![..]`; `None` otherwise. (complexity: 4) + pub(crate) fn inner_vec_push_args( + &self, + object: &Expr, + method: &str, + args: &[Expr], + ) -> Option> { + let ExprKind::Identifier(name) = &object.kind else { + return None; + }; + if !matches!(method, "push" | "insert") || !self.inner_vec_lists.borrow().contains(name) { + return None; + } + let mut rewritten = args.to_vec(); + let element = rewritten.last_mut()?; + *element = list_as_vec(element); + Some(rewritten) + } + + /// LETVEC-1: transpile one statement of a block. A statement-level `let` + /// (Unit body) whose scope is the following siblings `rest` gets its + /// array literal emitted as `vec![..]` when a sibling grows the binding. + pub(crate) fn transpile_block_statement( + &self, + expr: &Expr, + rest: &[Expr], + ) -> Result { + match grown_let_as_vec(expr, rest) { + Some(rewritten) => self.transpile_expr(&rewritten), + None => self.transpile_expr(expr), + } + } + /// Process value for let-with-type, handling string/list conversions fn process_let_value_with_type( &self, @@ -343,7 +447,7 @@ impl Transpiler { self.string_vars.borrow_mut().insert(name.to_string()); Ok((self.transpile_expr(value)?, false)) } - _ => Ok((self.transpile_expr(value)?, false)), + _ => Ok((self.transpile_let_value(name, value)?, false)), } } @@ -509,6 +613,93 @@ impl Transpiler { } } +/// LETVEC-1: a copy of the statement-level `let` `expr` with its non-empty +/// array literal replaced by `vec![..]`, when a later sibling in `rest` is a +/// Vec-only method call on the binding; `None` otherwise. NESTARRVEC-1: when +/// a sibling grows an element (`m[i].push(x)`), the inner literals become +/// `vec![..]` too. +fn grown_let_as_vec(expr: &Expr, rest: &[Expr]) -> Option { + let ExprKind::Let { + name, value, body, .. + } = &expr.kind + else { + return None; + }; + let is_list = matches!(&value.kind, ExprKind::List(items) if !items.is_empty()); + let is_statement = matches!(body.kind, ExprKind::Literal(Literal::Unit)); + if !is_list || !is_statement { + return None; + } + let grown = super::mutation_detection::is_grown_in_statements(name, rest); + let elem_grown = super::mutation_detection::is_elem_grown_in_statements(name, rest); + if !grown && !elem_grown { + return None; + } + let inner = if elem_grown { + inner_lists_as_vec(value) + } else { + (**value).clone() + }; + let mut rewritten = expr.clone(); + if let ExprKind::Let { value, .. } = &mut rewritten.kind { + **value = if grown { list_as_vec(&inner) } else { inner }; + } + Some(rewritten) +} + +/// NESTARRVEC-1: `value` with its inner array literals as `vec![..]`, when +/// `value` is an array literal and `body` grows an element of `name`. +fn elem_grown_value(name: &str, value: &Expr, body: &Expr) -> Option { + let is_list = matches!(&value.kind, ExprKind::List(items) if !items.is_empty()); + (is_list && super::mutation_detection::is_elem_grown_as_vec(name, body)) + .then(|| inner_lists_as_vec(value)) +} + +/// NESTARRVEC-1: a copy of the array literal `value` with each element that +/// is itself an array literal replaced by `vec![..]`. +fn inner_lists_as_vec(value: &Expr) -> Expr { + let mut rewritten = value.clone(); + if let ExprKind::List(items) = &mut rewritten.kind { + for item in items.iter_mut() { + *item = list_as_vec(item); + } + } + rewritten +} + +/// NESTPUSHLIT-1: `value` is a list literal (array or `vec![..]`) with an +/// element that is a `vec![..]` literal. (complexity: 4) +fn has_vec_inner_literals(value: &Expr) -> bool { + let items = match &value.kind { + ExprKind::List(items) => items, + ExprKind::MacroInvocation { name, args } if name == "vec" => args, + _ => return false, + }; + items + .iter() + .any(|item| matches!(&item.kind, ExprKind::MacroInvocation { name, .. } if name == "vec")) +} + +/// LETVEC-1: the array literal `list` as `vec![..]`; any other expression unchanged. +fn list_as_vec(list: &Expr) -> Expr { + let mut rewritten = list.clone(); + if let ExprKind::List(items) = &list.kind { + rewritten.kind = ExprKind::MacroInvocation { + name: "vec".to_string(), + args: items.clone(), + }; + } + rewritten +} + +/// LETVEC-1: `Vec<..>` annotation (a bare `Vec` or a generic `Vec`). +fn is_vec_annotation(ty: &Type) -> bool { + match &ty.kind { + TypeKind::Generic { base, .. } | TypeKind::Named(base) => base == "Vec", + _ => false, + } +} + // ============================================================================ // Tests // ============================================================================ @@ -517,6 +708,7 @@ impl Transpiler { mod tests { use super::*; use crate::frontend::ast::Span; + use quote::format_ident; fn make_expr(kind: ExprKind) -> Expr { Expr { @@ -995,4 +1187,19 @@ mod tests { let expr = ident_expr("items"); assert!(!transpiler.value_creates_vec(&expr)); } + + /// RHLGA-1 review F7: a keyword that cannot be a raw identifier + /// (`self`, `Self`, `super`, `crate`) must not make `let` panic. + #[test] + fn test_rhlga_1_let_named_non_raw_keyword_does_not_panic() { + let transpiler = Transpiler::new(); + for name in ["self", "Self", "super", "crate"] { + let value = int_expr(1); + let body = unit_expr(); + let plain = transpiler.transpile_let(name, &value, &body, false); + assert!(plain.is_ok(), "{name}: {plain:?}"); + let typed = transpiler.transpile_let_with_type(name, None, &value, &body, false, false); + assert!(typed.is_ok(), "{name}: {typed:?}"); + } + } } diff --git a/src/backend/transpiler/block_categorization.rs b/src/backend/transpiler/block_categorization.rs index 7614e145f..43465a358 100644 --- a/src/backend/transpiler/block_categorization.rs +++ b/src/backend/transpiler/block_categorization.rs @@ -401,7 +401,10 @@ impl Transpiler { /// Complexity: 2 (within Toyota Way limits) fn is_statement_call(func: &Expr) -> bool { if let ExprKind::Identifier(name) = &func.kind { - matches!(name.as_str(), "println" | "print" | "dbg") + matches!( + name.as_str(), + "println" | "print" | "eprintln" | "eprint" | "dbg" + ) } else { false } diff --git a/src/backend/transpiler/block_transpiler.rs b/src/backend/transpiler/block_transpiler.rs index b76c421f0..917d5af2a 100644 --- a/src/backend/transpiler/block_transpiler.rs +++ b/src/backend/transpiler/block_transpiler.rs @@ -9,7 +9,7 @@ use super::Transpiler; use crate::frontend::ast::{Expr, ExprKind, PipelineStage}; use anyhow::Result; use proc_macro2::TokenStream; -use quote::{format_ident, quote}; +use quote::quote; impl Transpiler { /// Transpile block expressions with smart brace handling @@ -29,7 +29,7 @@ impl Transpiler { let mut statements = Vec::new(); for (i, expr) in exprs.iter().enumerate() { - let expr_tokens = self.transpile_expr(expr)?; + let expr_tokens = self.transpile_block_statement(expr, &exprs[i + 1..])?; let is_last = i == exprs.len() - 1; let is_let = Self::is_let_expr(expr); @@ -88,7 +88,7 @@ impl Transpiler { Ok(quote! { #func_tokens(#prev #(, #arg_tokens)*) }) } ExprKind::MethodCall { method, args, .. } => { - let method_ident = format_ident!("{}", method); + let method_ident = Transpiler::safe_ident(method); // RAWIDENT-1 let arg_tokens: Result> = args.iter().map(|a| self.transpile_expr(a)).collect(); let arg_tokens = arg_tokens?; @@ -109,7 +109,7 @@ impl Transpiler { let mut statements = Vec::new(); for (i, expr) in exprs.iter().enumerate() { - let expr_tokens = self.transpile_expr(expr)?; + let expr_tokens = self.transpile_block_statement(expr, &exprs[i + 1..])?; let is_last = i == exprs.len() - 1; let is_let = Self::is_let_expr(expr); diff --git a/src/backend/transpiler/body_generation.rs b/src/backend/transpiler/body_generation.rs index 1d5900278..f05c01014 100644 --- a/src/backend/transpiler/body_generation.rs +++ b/src/backend/transpiler/body_generation.rs @@ -70,7 +70,7 @@ impl Transpiler { let mut statements = Vec::new(); for (i, expr) in exprs.iter().enumerate() { - let expr_tokens = self.transpile_expr(expr)?; + let expr_tokens = self.transpile_block_statement(expr, &exprs[i + 1..])?; let is_let = Self::is_let_expression(expr); if i < exprs.len() - 1 { diff --git a/src/backend/transpiler/call_helpers.rs b/src/backend/transpiler/call_helpers.rs index 0a4fb7c05..4a06cbdd9 100644 --- a/src/backend/transpiler/call_helpers.rs +++ b/src/backend/transpiler/call_helpers.rs @@ -97,7 +97,14 @@ impl Transpiler { } base_tokens = quote! { #base_tokens.clone() }; } - self.apply_string_coercion(arg, &base_tokens, expected_type) + let tokens = + self.apply_string_coercion(arg, &base_tokens, expected_type)?; + // INTLEN-1: a `usize` argument to an integer parameter is cast. + Ok(super::return_type_helpers::cast_usize_tokens( + arg, + expected_type, + tokens, + )) } else { // DEFECT-018 FIX: Auto-clone Identifier arguments in loop contexts // to prevent "use of moved value" errors on subsequent iterations @@ -172,7 +179,10 @@ impl Transpiler { // BOOK-COMPAT-017: Array literal to Any parameter (function with inferred Vec type) // Convert array [1, 2, 3] to vec via .to_vec() for functions expecting Vec // But NOT when expected type is explicit array type [T; N] - (ExprKind::List(elements), "Any" | "Unknown") if !elements.is_empty() => { + // ARRVECARG-1: likewise for a parameter annotated `Vec` + (ExprKind::List(elements), expected) + if !elements.is_empty() && expects_vec_param(expected) => + { Ok(quote! { #tokens.to_vec() }) } // Array literal to explicit array type [T; N]: keep as-is @@ -199,6 +209,13 @@ impl Transpiler { } } +/// ARRVECARG-1: a parameter that takes an array literal as a `Vec`: an +/// inferred (`Any`/`Unknown`) parameter or one annotated `Vec`. +/// (complexity: 2) +fn expects_vec_param(expected: &str) -> bool { + matches!(expected, "Any" | "Unknown") || expected.starts_with("Vec<") +} + // ============================================================================ // Tests // ============================================================================ diff --git a/src/backend/transpiler/call_transpilation.rs b/src/backend/transpiler/call_transpilation.rs index b841f6dba..c390cf1df 100644 --- a/src/backend/transpiler/call_transpilation.rs +++ b/src/backend/transpiler/call_transpilation.rs @@ -36,6 +36,50 @@ impl Transpiler { self.transpile_regular_function_call(&func_tokens, args) } + /// PARSER-094 / G2B-T5: Render a callee written `a::b::c` as a Rust path. + /// The parser marks each `::` segment (`Expr::is_path_access`); a `.` access + /// is never part of a path. + fn module_call_path(func: &Expr) -> Option { + if !func.is_path_access() { + return None; + } + let segments = Self::callee_path_segments(func)?; + let idents = segments.iter().map(|s| Self::safe_ident(s)); + Some(quote! { #(#idents)::* }) + } + + /// Collect the identifier segments of a `::` chain; None if any segment is + /// not a plain identifier or is joined by `.` instead of `::`. + fn callee_path_segments(expr: &Expr) -> Option> { + match &expr.kind { + ExprKind::Identifier(name) if Self::is_plain_ident(name) => Some(vec![name.clone()]), + ExprKind::FieldAccess { object, field } + if expr.is_path_access() && Self::is_plain_ident(field) => + { + let mut segments = Self::callee_path_segments(object)?; + segments.push(field.clone()); + Some(segments) + } + _ => None, + } + } + + fn is_plain_ident(name: &str) -> bool { + name.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_') + && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') + } + + /// G2B-T5: a `.` callee is a field holding a callable, called as `(obj.f)(..)`; + /// Rust would read `obj.f(..)` as a method call. + fn parenthesize_field_callee(func: &Expr, tokens: TokenStream) -> TokenStream { + let is_field = matches!(func.kind, ExprKind::FieldAccess { .. }) && !func.is_path_access(); + if is_field { + quote! { (#tokens) } + } else { + tokens + } + } + /// Transform main() calls to __ruchy_main() fn transform_main_call(&self, func: &Expr) -> Result { if let ExprKind::Identifier(name) = &func.kind { @@ -44,7 +88,12 @@ impl Transpiler { return Ok(quote! { #renamed_ident }); } } - self.transpile_expr(func) + // PARSER-094 (Issue #137): a `module::function(..)` callee keeps `::`. + if let Some(path_tokens) = Self::module_call_path(func) { + return Ok(path_tokens); + } + let tokens = self.transpile_expr(func)?; + Ok(Self::parenthesize_field_callee(func, tokens)) } /// Try to transpile std::time::now_millis() calls @@ -94,6 +143,9 @@ impl Transpiler { if let Some(result) = self.try_transpile_print_macro(func_tokens, base_name, args)? { return Ok(Some(result)); } + if let Some(result) = self.try_transpile_format_fn(name, args)? { + return Ok(Some(result)); + } // len(x) → x.len() if base_name == "len" && args.len() == 1 { @@ -264,7 +316,7 @@ impl Transpiler { if let ExprKind::Identifier(name) = &object.kind { if self.module_names.contains(name) { let module_ident = format_ident!("{}", name); - let method_ident = format_ident!("{}", method); + let method_ident = Transpiler::safe_ident(method); // RAWIDENT-1 let arg_tokens: Result> = args.iter().map(|a| self.transpile_expr(a)).collect(); let arg_tokens = arg_tokens?; @@ -272,8 +324,13 @@ impl Transpiler { } } - let obj_tokens = self.transpile_expr(object)?; - let method_ident = format_ident!("{}", method); + // IDXASSIGN-1: a mutating method's receiver chain is a place + let obj_tokens = if super::mutation_detection::MUTATING_STD_METHODS.contains(&method) { + self.transpile_place(object)? + } else { + self.transpile_expr(object)? + }; + let method_ident = Transpiler::safe_ident(method); // RAWIDENT-1 let arg_tokens: Result> = args.iter().map(|a| self.transpile_expr(a)).collect(); let arg_tokens = arg_tokens?; @@ -307,6 +364,71 @@ impl Transpiler { ) } + /// RHLGA-1: a range receiver (`a..b`, `range(a, b)`) is already an iterator. + /// `range(..)` is the builtin only when the program defines no `range` function. + fn is_range_receiver(&self, object: &Expr) -> bool { + match &object.kind { + ExprKind::Range { .. } => true, + ExprKind::Call { func, args } => { + matches!(&func.kind, ExprKind::Identifier(name) if name == "range") + && matches!(args.len(), 1 | 2) + && !self.function_signatures.contains_key("range") + } + _ => false, + } + } + + /// COUNTITER-1: a call whose Rust form already is an iterator + /// (`s.chars()`, `v.iter()`, `it.rev()`, ...). `map`/`filter` are not + /// listed: they are emitted with `.collect::>()`. (complexity: 2) + fn is_iterator_method_call(object: &Expr) -> bool { + matches!(&object.kind, ExprKind::MethodCall { method, .. } if matches!( + method.as_str(), + "iter" | "iter_mut" | "into_iter" | "chars" | "bytes" | "char_indices" + | "lines" | "split_whitespace" | "enumerate" | "rev" | "skip" | "take" + | "step_by" | "zip" + )) + } + + /// `a..b` needs parentheses to take a method; `range(a, b)` emits them itself. + fn parenthesize_range_literal(obj_tokens: &TokenStream, object: &Expr) -> TokenStream { + if matches!(object.kind, ExprKind::Range { .. }) { + quote! { (#obj_tokens) } + } else { + obj_tokens.clone() + } + } + + /// `count`/`sum`/`min`/`max` on a non-DataFrame receiver. + /// A collection is iterated by reference (`.iter()`, `.copied()` for + /// min/max); a range is called directly, parenthesised when it is a + /// range literal so the method binds to the whole range. + fn transpile_collection_aggregate( + &self, + obj_tokens: &TokenStream, + method: &str, + method_ident: &proc_macro2::Ident, + object: &Expr, + ) -> TokenStream { + let (iter, copied) = if self.is_range_receiver(object) { + let range = Self::parenthesize_range_literal(obj_tokens, object); + (range.clone(), range) + } else if Self::is_iterator_method_call(object) { + (obj_tokens.clone(), obj_tokens.clone()) + } else { + ( + quote! { #obj_tokens.iter() }, + quote! { #obj_tokens.iter().copied() }, + ) + }; + match method { + // TRANSPILER-ITERATOR-001: sum defaults to i32 (collect loses the type) + "sum" => quote! { #iter.sum::() }, + "count" => quote! { #iter.count() }, + _ => quote! { #copied.#method_ident() }, + } + } + /// Dispatch method call by category pub(super) fn dispatch_method_by_category( &self, @@ -338,29 +460,16 @@ impl Transpiler { Ok(quote! { #obj_tokens.#method_ident(#(#arg_tokens),*) }) } // Aggregation methods that work on both DataFrame and collections - "sum" => { - if Transpiler::is_dataframe_expr(object) { - self.transpile_dataframe_method(object, method, &[]) - } else { - // TRANSPILER-ITERATOR-001 FIX: For collections, use .iter().sum::() - // Default to i32 for sum since collect::>() loses type info - Ok(quote! { #obj_tokens.iter().sum::() }) - } - } - "min" | "max" => { - if Transpiler::is_dataframe_expr(object) { - self.transpile_dataframe_method(object, method, &[]) - } else { - // min/max return Option, use .copied() for ownership - Ok(quote! { #obj_tokens.iter().copied().#method_ident() }) - } - } - "count" => { + "sum" | "min" | "max" | "count" => { if Transpiler::is_dataframe_expr(object) { self.transpile_dataframe_method(object, method, &[]) } else { - // count returns usize - Ok(quote! { #obj_tokens.iter().count() }) + Ok(self.transpile_collection_aggregate( + obj_tokens, + method, + method_ident, + object, + )) } } // DataFrame-only operations @@ -382,7 +491,9 @@ impl Transpiler { | "substring" | "strip" | "lstrip" | "rstrip" | "startswith" | "endswith" | "split" | "replace" => self.transpile_string_methods(obj_tokens, method, arg_tokens), // List methods - "append" => Ok(quote! { #obj_tokens.push(#(#arg_tokens),*) }), + // LETVEC-1: Ruchy append(x) copies x's elements in and leaves x + // usable; extend_from_slice borrows a Vec or an array argument. + "append" => Ok(quote! { #obj_tokens.extend_from_slice(#(&#arg_tokens),*) }), "extend" => Ok(quote! { #obj_tokens.extend(#(#arg_tokens),*) }), // Collection methods "push" | "pop" | "contains" => { @@ -434,6 +545,26 @@ mod tests { make_expr(ExprKind::Literal(Literal::Integer(n, None))) } + #[test] + fn test_countiter_1_count_on_iterator_receiver() { + let transpiler = make_transpiler(); + for (src, want) in [ + ("s.chars().count()", "s.chars().count()"), + ("v.iter().count()", "v.iter().count()"), + ("v.count()", "v.iter().count()"), + ] { + let ast = crate::frontend::parser::Parser::new(src) + .parse() + .expect("parse"); + let got = transpiler + .transpile_expr(&ast) + .expect("transpile") + .to_string() + .replace(' ', ""); + assert_eq!(got, want, "{src}"); + } + } + fn string_expr(s: &str) -> Expr { make_expr(ExprKind::Literal(Literal::String(s.to_string()))) } @@ -561,7 +692,7 @@ mod tests { } #[test] - fn test_dispatch_append_to_push() { + fn test_dispatch_append_to_extend() { let transpiler = make_transpiler(); let obj_tokens = quote! { vec }; let arg_tokens = vec![quote! { 42 }]; @@ -570,7 +701,7 @@ mod tests { let result = transpiler .dispatch_method_by_category(&obj_tokens, "append", &method_ident, &arg_tokens, &object) .unwrap(); - assert!(result.to_string().contains("push")); + assert!(result.to_string().contains("extend")); } #[test] diff --git a/src/backend/transpiler/control_flow.rs b/src/backend/transpiler/control_flow.rs index fa81fe920..27290c465 100644 --- a/src/backend/transpiler/control_flow.rs +++ b/src/backend/transpiler/control_flow.rs @@ -70,8 +70,13 @@ impl Transpiler { // DEFECT-018 FIX: Set loop context flag to enable auto-cloning in function calls let was_in_loop = self.in_loop_context.get(); self.in_loop_context.set(true); - let body_tokens = self.transpile_expr(body)?; + // PRINTSTRSCOPE-1: the loop variable shadows any outer string record. + let loop_pattern = pattern + .cloned() + .unwrap_or_else(|| Pattern::Identifier(var.to_string())); + let body_tokens = self.with_for_scope(&loop_pattern, iter, || self.transpile_expr(body)); self.in_loop_context.set(was_in_loop); + let body_tokens = body_tokens?; // If we have a pattern, use it for destructuring if let Some(pat) = pattern { @@ -121,7 +126,7 @@ impl Transpiler { ) -> Result { let expr_tokens = self.transpile_expr(expr)?; let pattern_tokens = self.transpile_pattern(pattern)?; - let then_tokens = self.transpile_expr(then_branch)?; + let then_tokens = self.with_pattern_scope(pattern, || self.transpile_expr(then_branch))?; if let Some(else_expr) = else_branch { let else_tokens = self.transpile_expr(else_expr)?; Ok(quote! { @@ -150,7 +155,7 @@ impl Transpiler { ) -> Result { let expr_tokens = self.transpile_expr(expr)?; let pattern_tokens = self.transpile_pattern(pattern)?; - let body_tokens = self.transpile_expr(body)?; + let body_tokens = self.with_pattern_scope(pattern, || self.transpile_expr(body))?; Ok(quote! { while let #pattern_tokens = #expr_tokens { #body_tokens diff --git a/src/backend/transpiler/dispatcher.rs b/src/backend/transpiler/dispatcher.rs index 39b95db4a..ae1b08378 100644 --- a/src/backend/transpiler/dispatcher.rs +++ b/src/backend/transpiler/dispatcher.rs @@ -239,7 +239,9 @@ impl Transpiler { self.transpile_struct_literal(name, fields, base.as_deref()) } ExprKind::ObjectLiteral { fields } => self.transpile_object_literal(fields), - ExprKind::FieldAccess { object, field } => self.transpile_field_access(object, field), + ExprKind::FieldAccess { object, field } => { + self.transpile_field_access_node(expr, object, field) + } ExprKind::IndexAccess { object, index } => self.transpile_index_access(object, index), ExprKind::Slice { object, start, end } => { self.transpile_slice(object, start.as_deref(), end.as_deref()) diff --git a/src/backend/transpiler/dispatcher_helpers/macro_helpers.rs b/src/backend/transpiler/dispatcher_helpers/macro_helpers.rs index 14057610e..3c5b95ade 100644 --- a/src/backend/transpiler/dispatcher_helpers/macro_helpers.rs +++ b/src/backend/transpiler/dispatcher_helpers/macro_helpers.rs @@ -83,7 +83,9 @@ impl Transpiler { // Check if first argument is a format string (contains {}) // TRANSPILER-DEFECT-007 FIX: Also recognize {:?}, {:#?}, {:x}, etc. as format strings let first_is_format_string = match &args[0].kind { - ExprKind::Literal(Literal::String(s)) => s.contains('{') && s.contains('}'), + ExprKind::Literal(Literal::String(s)) => { + super::super::print_helpers::has_format_placeholder(s) + } _ => false, }; diff --git a/src/backend/transpiler/dispatcher_helpers/misc.rs b/src/backend/transpiler/dispatcher_helpers/misc.rs index 298456af1..e2f4e45a9 100644 --- a/src/backend/transpiler/dispatcher_helpers/misc.rs +++ b/src/backend/transpiler/dispatcher_helpers/misc.rs @@ -54,6 +54,7 @@ impl Transpiler { // In match arms, return is an expression and shouldn't have trailing semicolon if let Some(val_expr) = value { let val_tokens = self.transpile_expr(val_expr)?; + let val_tokens = self.cast_usize_return(val_expr, val_tokens); Ok(quote! { return #val_tokens }) } else { Ok(quote! { return }) @@ -63,6 +64,20 @@ impl Transpiler { } } + /// RETLEN-1: an explicit `return ` in a function whose + /// declared return type is an integer type is cast to it (INTLEN-1 casts + /// only tails, lets and arguments). (complexity: 2) + fn cast_usize_return(&self, value: &Expr, tokens: TokenStream) -> TokenStream { + match self.current_function_return_type.borrow().as_ref() { + Some(ty) => crate::backend::transpiler::return_type_helpers::cast_usize_tokens( + value, + &Self::type_to_string(ty), + tokens, + ), + None => tokens, + } + } + pub(in crate::backend::transpiler) fn make_break_continue( is_break: bool, label: Option<&String>, diff --git a/src/backend/transpiler/expr_dispatcher.rs b/src/backend/transpiler/expr_dispatcher.rs index ecb6a7020..c657a2e8f 100644 --- a/src/backend/transpiler/expr_dispatcher.rs +++ b/src/backend/transpiler/expr_dispatcher.rs @@ -72,6 +72,22 @@ impl Transpiler { ) } + /// RAWIDENT-1: A function/method name that collides with a Rust reserved + /// word (but not a Ruchy keyword, e.g. `do`, `box`, `typeof`) must be + /// emitted as a raw identifier so rustc accepts the definition/call. + /// `self`, `Self`, `super`, `crate`, `_` can never be raw identifiers; + /// they are returned verbatim (Ruchy never emits them as fn/method names + /// anyway, but the check keeps this helper safe to call on any name). + /// Complexity: 2 (within Toyota Way limits) + pub(crate) fn safe_ident(name: &str) -> proc_macro2::Ident { + let cannot_be_raw = matches!(name, "self" | "Self" | "super" | "crate" | "_"); + if !cannot_be_raw && Self::is_rust_reserved_keyword(name) { + proc_macro2::Ident::new_raw(name, proc_macro2::Span::call_site()) + } else { + proc_macro2::Ident::new(name, proc_macro2::Span::call_site()) + } + } + /// Main expression transpilation dispatcher /// /// Routes expressions to specialized handlers based on ExprKind. diff --git a/src/backend/transpiler/expressions.rs b/src/backend/transpiler/expressions.rs index e16d5dad9..fa7ec65bf 100644 --- a/src/backend/transpiler/expressions.rs +++ b/src/backend/transpiler/expressions.rs @@ -144,17 +144,39 @@ impl Transpiler { } } + // DICTIDX-1: `d["k"] = v` inserts into the map + if let Some(tokens) = self.try_transpile_map_key_assign(target, value)? { + return Ok(tokens); + } + // Standard assignment (no deadlock risk) let value_tokens = self.transpile_expr(value)?; - // BUG-003: Handle IndexAccess specially for lvalue (no .clone()) - if let ExprKind::IndexAccess { .. } = &target.kind { - let target_tokens = self.transpile_index_lvalue(target)?; - Ok(quote! { #target_tokens = #value_tokens }) - } else { - let target_tokens = self.transpile_expr(target)?; - Ok(quote! { #target_tokens = #value_tokens }) + // BUG-003 / IDXASSIGN-1: the target is a place (no `.clone()` in its chain) + let target_tokens = self.transpile_place(target)?; + Ok(quote! { #target_tokens = #value_tokens }) + } + + /// DICTIDX-1: `map["k"] = v` is `map.insert("k".to_string(), v)`; the + /// value is converted as a dict-literal value is. (complexity: 2) + fn try_transpile_map_key_assign( + &self, + target: &Expr, + value: &Expr, + ) -> Result> { + let ExprKind::IndexAccess { object, index } = &target.kind else { + return Ok(None); + }; + if !is_string_key(index) { + return Ok(None); } + let obj_tokens = self.transpile_place(object)?; + let key_tokens = self.transpile_expr(index)?; + // DICTTYPE-1: a typed-value dict stores the value as is + let value_tokens = self.map_value_tokens(value, self.is_typed_dict(object))?; + Ok(Some( + quote! { #obj_tokens.insert(#key_tokens.to_string(), #value_tokens) }, + )) } /// Check if an expression references a specific variable name @@ -287,23 +309,60 @@ impl Transpiler { } } - /// Transpile `IndexAccess` as an lvalue (no .`clone()`) - /// Handles nested cases like matrix[i][j] - fn transpile_index_lvalue(&self, expr: &Expr) -> Result { + /// IDXASSIGN-1: transpile `expr` in place (lvalue) position. + /// + /// A field/index chain that contains an index (`o.items[0].z`, + /// `a.b[i].c[j].d`, `m[i][j]`) is emitted with no `.clone()` anywhere in + /// it, so the assignment, compound assignment, mutating method call or + /// `&mut` borrow reaches the original element instead of a temporary. + /// Any other expression is transpiled as a value. (complexity: 4) + pub(crate) fn transpile_place(&self, expr: &Expr) -> Result { + if !Self::place_chain_has_index(expr) { + return self.transpile_expr(expr); + } match &expr.kind { ExprKind::IndexAccess { object, index } => { - // Recursively handle nested IndexAccess - let obj_tokens = if matches!(object.kind, ExprKind::IndexAccess { .. }) { - self.transpile_index_lvalue(object)? - } else { - self.transpile_expr(object)? - }; + let obj_tokens = self.transpile_place(object)?; let idx_tokens = self.transpile_expr(index)?; + if is_string_key(index) { + // DICTIDX-1: a string key reaches the map entry in place + return Ok( + quote! { (*#obj_tokens.get_mut(#idx_tokens).expect("Key not found")) }, + ); + } Ok(quote! { #obj_tokens[#idx_tokens as usize] }) } + ExprKind::FieldAccess { object, field } => { + let obj_tokens = self.transpile_place(object)?; + let field_tokens = Self::place_field_tokens(field); + Ok(quote! { #obj_tokens.#field_tokens }) + } _ => self.transpile_expr(expr), } } + + /// True when a field/index chain contains an index access. (complexity: 3) + fn place_chain_has_index(expr: &Expr) -> bool { + match &expr.kind { + ExprKind::IndexAccess { .. } => true, + ExprKind::FieldAccess { object, .. } => Self::place_chain_has_index(object), + _ => false, + } + } + + /// A struct field name or a tuple index (`.0`) of a place. (complexity: 2) + fn place_field_tokens(field: &str) -> TokenStream { + match field.parse::() { + Ok(index) => { + let index = syn::Index::from(index); + quote! { #index } + } + Err(_) => { + let ident = Self::safe_ident(field); + quote! { #ident } + } + } + } /// Transpiles compound assignment /// # Examples /// @@ -346,8 +405,8 @@ impl Transpiler { } } - // Standard compound assignment (non-global) - let target_tokens = self.transpile_expr(target)?; + // Standard compound assignment (non-global); IDXASSIGN-1: target is a place + let target_tokens = self.transpile_place(target)?; let value_tokens = self.transpile_expr(value)?; let op_tokens = Self::get_compound_op_token(op)?; Ok(quote! { #target_tokens #op_tokens #value_tokens }) @@ -1225,7 +1284,7 @@ mod tests { // Test 58: transpile_index_lvalue simple #[test] - fn test_transpile_index_lvalue_simple() { + fn test_transpile_place_index_simple() { let transpiler = Transpiler::new(); let object = Expr::new(ExprKind::Identifier("arr".to_string()), Span::default()); let index = Expr::new( @@ -1239,7 +1298,7 @@ mod tests { }, Span::default(), ); - let result = transpiler.transpile_index_lvalue(&expr); + let result = transpiler.transpile_place(&expr); assert!(result.is_ok()); let code = result.expect("should succeed").to_string(); assert!(code.contains("arr")); @@ -1248,7 +1307,7 @@ mod tests { // Test 59: transpile_index_lvalue nested #[test] - fn test_transpile_index_lvalue_nested() { + fn test_transpile_place_index_nested() { let transpiler = Transpiler::new(); let matrix = Expr::new(ExprKind::Identifier("matrix".to_string()), Span::default()); let i = Expr::new( @@ -1273,18 +1332,58 @@ mod tests { }, Span::default(), ); - let result = transpiler.transpile_index_lvalue(&expr); + let result = transpiler.transpile_place(&expr); assert!(result.is_ok()); let code = result.expect("should succeed").to_string(); assert!(code.contains("matrix")); + assert!(!code.contains("clone"), "{code}"); + } + + // IDXASSIGN-1: a field of an indexed element is a place, not a clone + #[test] + fn test_transpile_place_field_of_index_has_no_clone() { + let transpiler = Transpiler::new(); + let items = Expr::new( + ExprKind::FieldAccess { + object: Box::new(Expr::new( + ExprKind::Identifier("o".to_string()), + Span::default(), + )), + field: "items".to_string(), + }, + Span::default(), + ); + let elem = Expr::new( + ExprKind::IndexAccess { + object: Box::new(items), + index: Box::new(Expr::new( + ExprKind::Literal(Literal::Integer(0, None)), + Span::default(), + )), + }, + Span::default(), + ); + let expr = Expr::new( + ExprKind::FieldAccess { + object: Box::new(elem), + field: "z".to_string(), + }, + Span::default(), + ); + let code = transpiler + .transpile_place(&expr) + .expect("place transpiles") + .to_string() + .replace(' ', ""); + assert_eq!(code, "o.items[0asusize].z"); } // Test 60: transpile_index_lvalue non-index #[test] - fn test_transpile_index_lvalue_non_index() { + fn test_transpile_place_index_non_index() { let transpiler = Transpiler::new(); let expr = Expr::new(ExprKind::Identifier("x".to_string()), Span::default()); - let result = transpiler.transpile_index_lvalue(&expr); + let result = transpiler.transpile_place(&expr); assert!(result.is_ok()); } @@ -1968,3 +2067,9 @@ mod tests { assert!(code.contains("42"), "Literal should pass through unchanged"); } } + +/// DICTIDX-1: `index` is a string literal, i.e. a map key rather than a +/// position (only a position gets the `as usize` cast). (complexity: 1) +pub(crate) fn is_string_key(index: &Expr) -> bool { + matches!(&index.kind, ExprKind::Literal(Literal::String(_))) +} diff --git a/src/backend/transpiler/expressions_helpers/async_ops.rs b/src/backend/transpiler/expressions_helpers/async_ops.rs index c0a91ea29..927efc6d7 100644 --- a/src/backend/transpiler/expressions_helpers/async_ops.rs +++ b/src/backend/transpiler/expressions_helpers/async_ops.rs @@ -8,7 +8,12 @@ use quote::{format_ident, quote}; impl Transpiler { pub fn transpile_unary(&self, op: UnaryOp, operand: &Expr) -> Result { - let mut operand_tokens = self.transpile_expr(operand)?; + // IDXASSIGN-1: `&mut o.items[0]` borrows the element, not a clone + let mut operand_tokens = if op == UnaryOp::MutableReference { + self.transpile_place(operand)? + } else { + self.transpile_expr(operand)? + }; // TRANSPILENOT-1: `!(b && x)` must not become `!b && x` if Self::unary_operand_needs_parens(operand) { operand_tokens = quote! { (#operand_tokens) }; diff --git a/src/backend/transpiler/expressions_helpers/collections.rs b/src/backend/transpiler/expressions_helpers/collections.rs index a8ced4030..57721787b 100644 --- a/src/backend/transpiler/expressions_helpers/collections.rs +++ b/src/backend/transpiler/expressions_helpers/collections.rs @@ -149,6 +149,11 @@ impl Transpiler { &self, fields: &[crate::frontend::ast::ObjectField], ) -> Result { + // DICTIDX-1 / DICTTYPE-1: a dict whose values are all lists, ints, + // floats or bools keeps its value type + if typed_value_fields(fields) { + return self.transpile_typed_value_object(fields); + } let field_tokens = self.collect_hashmap_field_tokens(fields)?; // DEFECT-DICT-DETERMINISM FIX: Use BTreeMap for deterministic key ordering // BTreeMap maintains sorted order, HashMap has non-deterministic iteration order @@ -160,6 +165,43 @@ impl Transpiler { } }) } + /// DICTIDX-1: `{"k": [..], ..}` is a `BTreeMap>`, so + /// `d["k"].push(x)` and `{:?}` work on the real list; DICTTYPE-1: likewise + /// `{"k": 1, ..}` is a map of ints (floats, bools). (complexity: 3) + fn transpile_typed_value_object( + &self, + fields: &[crate::frontend::ast::ObjectField], + ) -> Result { + use crate::frontend::ast::ObjectField; + let mut inserts = Vec::new(); + for field in fields { + if let ObjectField::KeyValue { key, value } = field { + let value_tokens = self.map_value_tokens(value, true)?; + inserts.push(quote! { map.insert(#key.to_string(), #value_tokens); }); + } + } + Ok(quote! { + { + let mut map: std::collections::BTreeMap = std::collections::BTreeMap::new(); + #(#inserts)* + map + } + }) + } + + /// DICTIDX-1: a dict value as stored in the map: a list literal is a + /// `Vec`; anything else is kept as is when `typed`, else stringified + /// (the `BTreeMap` of a dict with non-list values). + /// (complexity: 3) + pub(crate) fn map_value_tokens(&self, value: &Expr, typed: bool) -> Result { + let tokens = self.transpile_expr(value)?; + Ok(match &value.kind { + ExprKind::List(_) => quote! { #tokens.to_vec() }, + _ if typed => tokens, + _ => quote! { (#tokens).to_string() }, + }) + } + fn collect_hashmap_field_tokens( &self, fields: &[crate::frontend::ast::ObjectField], @@ -199,6 +241,23 @@ impl Transpiler { /// let result = transpiler.transpile_struct_literal("Point", &fields, None); /// assert!(result.is_ok()); /// ``` + /// VECLIT-1: an array literal initializing a field declared `Vec<…>` is + /// emitted as `vec![…]`; a Rust array does not coerce to `Vec` (E0308). + fn transpile_field_value( + &self, + field_type: Option<&String>, + value: &Expr, + ) -> Result { + let tokens = self.transpile_expr(value)?; + let is_list = matches!(value.kind, ExprKind::List(_)); + let is_vec_field = field_type.is_some_and(|t| is_vec_type_name(t)); + let is_array_tokens = tokens.to_string().starts_with('['); + if is_list && is_vec_field && is_array_tokens { + return Ok(quote! { vec! #tokens }); + } + Ok(tokens) + } + pub fn transpile_struct_literal( &self, name: &str, @@ -229,10 +288,10 @@ impl Transpiler { // BOOK-COMPAT-007B: Check for auto-boxed recursive types let auto_boxed = self.auto_boxed_fields.borrow(); for (field_name, value) in fields { - let field_ident = format_ident!("{}", field_name); - // BOOK-COMPAT-002 FIX: Add .to_string() for String fields with string literals - // When a struct field is typed as String and the value is a string literal, - // we need to add .to_string() for the Rust code to compile correctly. + let field_ident = Self::safe_ident(field_name); // RESFIELD-1 + // BOOK-COMPAT-002 FIX: Add .to_string() for String fields with string literals + // When a struct field is typed as String and the value is a string literal, + // we need to add .to_string() for the Rust code to compile correctly. let field_type = field_types.get(&(base_struct_name.to_string(), field_name.clone())); let needs_to_string = matches!(field_type, Some(t) if t == "String") && matches!(&value.kind, ExprKind::Literal(Literal::String(_))); @@ -254,7 +313,7 @@ impl Transpiler { field_tokens.push(quote! { #field_ident: Some(Box::new(#inner_tokens)) }); } } else { - let value_tokens = self.transpile_expr(value)?; + let value_tokens = self.transpile_field_value(field_type, value)?; if needs_to_string { field_tokens.push(quote! { #field_ident: #value_tokens.to_string() }); } else { @@ -312,6 +371,12 @@ impl Transpiler { } } +/// VECLIT-1: the recorded type of a struct field is `Vec` or a +/// `Vec<…>` generic (stored as the `TypeKind` debug text by the struct pass). +fn is_vec_type_name(type_name: &str) -> bool { + type_name == "Vec" || type_name.starts_with("Generic { base: \"Vec\"") +} + #[cfg(test)] mod tests { use super::*; @@ -879,3 +944,73 @@ mod tests { assert!(result_str.contains("insert")); } } + +/// DICTTYPE-1: `variable_types` record of a binding to a dict literal whose +/// values keep their type (see [`typed_value_fields`]). +pub(crate) const TYPED_DICT_VAR_TYPE: &str = "__ruchy_typed_dict"; + +/// DICTTYPE-1: the class of a dict value that keeps its own type in the map. +#[derive(Clone, Copy, PartialEq, Eq)] +enum DictValueKind { + List, + Int, + Float, + Bool, +} + +/// DICTTYPE-1: the typed class of a dict value, if it has one: a list +/// literal, an int, float or bool literal, or a negated number. +/// (complexity: 6) +fn dict_value_kind(value: &Expr) -> Option { + use crate::frontend::ast::UnaryOp; + match &value.kind { + ExprKind::List(_) => Some(DictValueKind::List), + ExprKind::Literal(Literal::Integer(..)) => Some(DictValueKind::Int), + ExprKind::Literal(Literal::Float(_)) => Some(DictValueKind::Float), + ExprKind::Literal(Literal::Bool(_)) => Some(DictValueKind::Bool), + ExprKind::Unary { + op: UnaryOp::Negate, + operand, + } => dict_value_kind(operand) + .filter(|k| matches!(k, DictValueKind::Int | DictValueKind::Float)), + _ => None, + } +} + +/// DICTIDX-1 / DICTTYPE-1: a non-empty dict literal (no spread) whose values +/// are all of one typed class: all lists, all ints, all floats or all bools. +/// Other dicts (strings, mixed) stay `BTreeMap`. +/// (complexity: 4) +fn typed_value_fields(fields: &[crate::frontend::ast::ObjectField]) -> bool { + use crate::frontend::ast::ObjectField; + let mut kinds = fields.iter().map(|f| match f { + ObjectField::KeyValue { value, .. } => dict_value_kind(value), + ObjectField::Spread { .. } => None, + }); + match kinds.next() { + Some(Some(first)) => kinds.all(|k| k == Some(first)), + _ => false, + } +} + +impl Transpiler { + /// DICTTYPE-1: record whether the binding `name` holds a typed-value dict + /// literal; a rebinding to anything else clears the record. (complexity: 3) + pub(crate) fn track_typed_dict_binding(&self, name: &str, value: &Expr) { + let typed = + matches!(&value.kind, ExprKind::ObjectLiteral { fields } if typed_value_fields(fields)); + let mut types = self.variable_types.borrow_mut(); + if typed { + types.insert(name.to_string(), TYPED_DICT_VAR_TYPE.to_string()); + } else if types.get(name).is_some_and(|t| t == TYPED_DICT_VAR_TYPE) { + types.remove(name); + } + } + + /// DICTTYPE-1: `expr` names a binding recorded as a typed-value dict. + /// (complexity: 2) + pub(crate) fn is_typed_dict(&self, expr: &Expr) -> bool { + matches!(&expr.kind, ExprKind::Identifier(name) + if self.variable_types.borrow().get(name).is_some_and(|t| t == TYPED_DICT_VAR_TYPE)) + } +} diff --git a/src/backend/transpiler/expressions_helpers/field_access.rs b/src/backend/transpiler/expressions_helpers/field_access.rs index 19302d2a6..d2c235391 100644 --- a/src/backend/transpiler/expressions_helpers/field_access.rs +++ b/src/backend/transpiler/expressions_helpers/field_access.rs @@ -4,7 +4,7 @@ use super::super::Transpiler; use crate::frontend::ast::Expr; use anyhow::Result; use proc_macro2::TokenStream; -use quote::{format_ident, quote}; +use quote::quote; impl Transpiler { /// Check if an expression represents a module path (like `std::time`) @@ -77,6 +77,29 @@ impl Transpiler { .any(|prefix| name.starts_with(prefix)) } + /// Transpile a `FieldAccess` node. When the parser marked it as written with + /// `::` ([`Expr::is_path_access`]) it is a path segment (`i32::MAX`, + /// `helpers::double`); otherwise the separator is guessed from the object by + /// [`Self::transpile_field_access`]. + pub fn transpile_field_access_node( + &self, + access: &Expr, + object: &Expr, + field: &str, + ) -> Result { + let is_ident = field + .chars() + .next() + .is_some_and(|c| c.is_alphabetic() || c == '_'); + if !(access.is_path_access() && is_ident) { + return self.transpile_field_access(object, field); + } + let obj_tokens = self.transpile_expr(object)?; + // A reserved segment used as a value (`let f = m::do`) is raw (`m::r#do`). + let field_ident = Self::safe_ident(field); + Ok(quote! { #obj_tokens::#field_ident }) + } + pub fn transpile_field_access(&self, object: &Expr, field: &str) -> Result { use crate::frontend::ast::ExprKind; let obj_tokens = self.transpile_expr(object)?; @@ -124,7 +147,7 @@ impl Transpiler { let known_methods = [ "success", "exists", "is_empty", "is_some", "is_none", "is_ok", "is_err", ]; - let field_ident = format_ident!("{}", field); + let field_ident = Self::safe_ident(field); // RESFIELD-1 if known_methods.contains(&field) { // Known method - use . and add () @@ -144,32 +167,32 @@ impl Transpiler { } ExprKind::Identifier(name) if name.contains("::") => { // Module path identifier - use :: syntax - let field_ident = format_ident!("{}", field); + let field_ident = Self::safe_ident(field); // RESFIELD-1 Ok(quote! { #obj_tokens::#field_ident }) } ExprKind::Identifier(name) if name == "std" => { // STDLIB-003: std module - use :: syntax for std::time, std::fs, etc. - let field_ident = format_ident!("{}", field); + let field_ident = Self::safe_ident(field); // RESFIELD-1 Ok(quote! { #obj_tokens::#field_ident }) } ExprKind::Identifier(name) if self.module_names.contains(name) => { // ISSUE-103: Module name - use :: syntax for module::function() // Examples: helper::get_message(), logger::log_info() - let field_ident = format_ident!("{}", field); + let field_ident = Self::safe_ident(field); // RESFIELD-1 Ok(quote! { #obj_tokens::#field_ident }) } ExprKind::Identifier(name) if name.chars().next().is_some_and(char::is_uppercase) => { // TRANSPILER-065: Type name (PascalCase) - use :: for associated functions/constructors // Examples: String::from(), Result::Ok(), Vec::new() // Heuristic: Rust types start with uppercase, instances with lowercase - let field_ident = format_ident!("{}", field); + let field_ident = Self::safe_ident(field); // RESFIELD-1 Ok(quote! { #obj_tokens::#field_ident }) } ExprKind::Identifier(name) if Self::is_module_like_identifier(name) => { // PARSER-094: Module-like identifier (lowercase_underscore pattern) // Examples: http_client::http_get(), my_module::function() // Issue #137: Fixes ruchy-lambda AWS Lambda runtime module calls - let field_ident = format_ident!("{}", field); + let field_ident = Self::safe_ident(field); // RESFIELD-1 Ok(quote! { #obj_tokens::#field_ident }) } _ => { @@ -187,7 +210,7 @@ impl Transpiler { let known_methods = [ "success", "exists", "is_empty", "is_some", "is_none", "is_ok", "is_err", ]; - let field_ident = format_ident!("{}", field); + let field_ident = Self::safe_ident(field); // RESFIELD-1 if known_methods.contains(&field) { // Known method - add () for method call diff --git a/src/backend/transpiler/function_analysis.rs b/src/backend/transpiler/function_analysis.rs index 9dbbd787c..15ea56f46 100644 --- a/src/backend/transpiler/function_analysis.rs +++ b/src/backend/transpiler/function_analysis.rs @@ -68,6 +68,45 @@ pub fn is_void_function_call(expr: &Expr) -> bool { } } +/// UNITRET-1: methods whose Rust counterpart returns `()` (complexity: 1) +/// +/// A function whose tail is one of these calls has no value to return, so no +/// return type is inferred for it. Only methods that return `()` on the +/// standard collections and `String` are listed; `pop`/`remove` return a +/// value and are not. +#[must_use] +pub fn is_unit_method(method: &str) -> bool { + matches!( + method, + "push" + | "push_str" + | "push_back" + | "push_front" + | "insert" + | "extend" + | "extend_from_slice" + | "append" + | "clear" + | "sort" + | "sort_unstable" + | "sort_by" + | "sort_by_key" + | "sort_unstable_by" + | "sort_unstable_by_key" + | "reverse" + | "truncate" + | "retain" + | "dedup" + | "dedup_by_key" + | "resize" + | "swap" + | "fill" + | "reserve" + | "shrink_to_fit" + | "for_each" + ) +} + /// Check if an expression is void (returns unit/nothing) /// /// Recursively analyzes expressions to determine if they produce no value. @@ -78,6 +117,8 @@ pub fn is_void_expression(expr: &Expr) -> bool { ExprKind::Literal(Literal::Unit) => true, // Void function calls ExprKind::Call { .. } if is_void_function_call(expr) => true, + // UNITRET-1: method calls that return unit in Rust (`xs.push(1)`) + ExprKind::MethodCall { method, .. } if is_unit_method(method) => true, // Macro invocations (println!, etc.) ExprKind::MacroInvocation { name, .. } if matches!(name.as_str(), "println" | "print" | "eprintln" | "eprint") => @@ -344,6 +385,42 @@ mod tests { assert!(is_void_function_call(&expr)); } + // ==================== UNITRET-1: unit method tails ==================== + + fn method_call(method: &str) -> Expr { + make_expr(ExprKind::MethodCall { + receiver: Box::new(ident("xs")), + method: method.to_string(), + args: vec![], + }) + } + + #[test] + fn test_unitret_1_unit_method_tail_is_void() { + for method in [ + "push", "insert", "extend", "clear", "sort", "reverse", "truncate", + ] { + assert!(is_void_expression(&method_call(method)), "{method}"); + let block = make_expr(ExprKind::Block(vec![method_call(method)])); + assert!(!has_non_unit_expression(&block), "{method}"); + } + } + + #[test] + fn test_unitret_1_value_method_tail_is_not_void() { + for method in [ + "len", + "pop", + "remove", + "abs", + "get", + "sum", + "unknown_method", + ] { + assert!(!is_void_expression(&method_call(method)), "{method}"); + } + } + // ==================== is_void_expression Tests ==================== #[test] diff --git a/src/backend/transpiler/function_signature.rs b/src/backend/transpiler/function_signature.rs index 339782248..5beff391a 100644 --- a/src/backend/transpiler/function_signature.rs +++ b/src/backend/transpiler/function_signature.rs @@ -77,6 +77,10 @@ impl Transpiler { return Ok(quote! { -> String }); } + if super::return_type_helpers::returns_usize(body) { + return Ok(quote! { -> usize }); + } + // BOOK-COMPAT-017: Check call-site types for numeric return type // Check if function has float arguments - if so, infer f64 return type let has_float_args = @@ -323,6 +327,9 @@ impl Transpiler { /// Complexity: 4 (within Toyota Way limits) pub(crate) fn format_regular_attribute_impl(&self, attr: &Attribute) -> TokenStream { let attr_name = format_ident!("{}", attr.name); + if attr.name == "test" { + return Self::format_test_attribute(&attr.args); + } if attr.args.is_empty() { quote! { #[#attr_name] } } else { @@ -335,6 +342,16 @@ impl Transpiler { } } + /// BUG-033: Rust's `#[test]` takes no arguments; `@test("description")` + /// keeps its description as a doc comment on the test function. + fn format_test_attribute(args: &[String]) -> TokenStream { + if args.is_empty() { + return quote! { #[test] }; + } + let doc = args.join(", ").trim_matches('"').to_string(); + quote! { #[doc = #doc] #[test] } + } + /// Compute final return type (handles special cases) /// Complexity: 1 (within Toyota Way limits) pub(crate) fn compute_final_return_type_impl( diff --git a/src/backend/transpiler/function_transpiler.rs b/src/backend/transpiler/function_transpiler.rs index 5714f1417..9eefd87f7 100644 --- a/src/backend/transpiler/function_transpiler.rs +++ b/src/backend/transpiler/function_transpiler.rs @@ -6,7 +6,7 @@ use crate::frontend::ast::{Expr, ExprKind, Param, Type, TypeKind}; use anyhow::Result; use proc_macro2::TokenStream; -use quote::{format_ident, quote}; +use quote::quote; use super::Transpiler; @@ -24,7 +24,9 @@ impl Transpiler { is_pub: bool, attributes: &[crate::frontend::ast::Attribute], ) -> Result { - let fn_name = format_ident!("{}", name); + // RAWIDENT-1: a Rust-reserved-but-not-Ruchy-keyword name (e.g. `do`, `box`, + // `typeof`) must be emitted as a raw identifier so rustc accepts the fn. + let fn_name = Transpiler::safe_ident(name); // Check if we need to add lifetime parameter let needs_lifetime = super::type_analysis::needs_lifetime_parameter(params, return_type); @@ -37,6 +39,11 @@ impl Transpiler { modified_type_params.insert(0, "'a".to_string()); } + // PRINTSTR-1: string parameters print with Display. + self.track_string_params(params); + // NESTPUSHLIT-1: nested-vec records of earlier functions end here. + self.inner_vec_lists.borrow_mut().clear(); + // TRANSPILER-004 FIX: Track String-typed parameters for proper concat transpilation // Before processing function body, register all String parameters in string_vars // This enables is_definitely_string() to detect them for `a + b` → `format!()` or `a + &b` @@ -62,6 +69,8 @@ impl Transpiler { } else { self.generate_param_tokens(params, body, name)? }; + // PRINTPARAM-1: an inferred String/&str parameter prints with Display. + self.track_emitted_string_params(¶m_tokens); // Check for #[test] attribute and override return type if found let has_test_attribute = attributes.iter().any(|attr| attr.name == "test"); @@ -84,7 +93,7 @@ impl Transpiler { } else { self.generate_body_tokens(body, is_async)? } - } else if name == "main" && Self::has_non_unit_last_expr(body) { + } else if name == "main" && self.has_non_unit_last_expr(body) { // BOOK-COMPAT-015: Main functions that end with a non-unit expression // should print it instead of trying to return it self.generate_main_body_with_print(body, is_async)? @@ -95,6 +104,10 @@ impl Transpiler { // TRANSPILER-007: Clear current function return type after body transpilation self.current_function_return_type.replace(None); + // RHLGA-1: rustc rejects `async fn main` (E0752) and `.await` in a sync fn (E0728). + let (body_tokens, is_async) = + self.adapt_awaiting_main(name, body, body_tokens, is_async, effective_return_type)?; + let return_type_tokens = if needs_lifetime { self.generate_return_type_tokens_with_lifetime(name, effective_return_type, body)? } else { @@ -115,22 +128,35 @@ impl Transpiler { } /// BOOK-COMPAT-015: Check if body has a non-unit last expression - fn has_non_unit_last_expr(body: &Expr) -> bool { + /// (MAINUNIT-1: a call to a unit-returning user function is unit; + /// CLOSUREUNIT-1: so is a call to a let-bound closure with a unit body) + fn has_non_unit_last_expr(&self, body: &Expr) -> bool { + self.has_non_unit_tail(body) && !tail_calls_unit_closure(body) + } + + /// BOOK-COMPAT-015: the last expression of `body` is not unit. + fn has_non_unit_tail(&self, body: &Expr) -> bool { match &body.kind { - ExprKind::Block(exprs) => { - if let Some(last) = exprs.last() { - Self::has_non_unit_last_expr(last) - } else { - false - } - } + ExprKind::Block(exprs) => exprs + .last() + .is_some_and(|last| self.has_non_unit_tail(last)), ExprKind::Let { body, .. } | ExprKind::LetPattern { body, .. } => { - Self::has_non_unit_last_expr(body) + self.has_non_unit_tail(body) } - _ => !Self::is_unit_expr(body), + _ => !Self::is_unit_expr(body) && !self.is_unit_function_call(body), } } + /// MAINUNIT-1: a call to a user function recorded as returning `()`. + /// (complexity: 2) + fn is_unit_function_call(&self, expr: &Expr) -> bool { + matches!( + &expr.kind, + ExprKind::Call { func, .. } + if matches!(&func.kind, ExprKind::Identifier(name) if self.unit_functions.contains(name)) + ) + } + /// Check if expression is a unit expression (doesn't return a value) fn is_unit_expr(expr: &Expr) -> bool { use crate::frontend::ast::Literal; @@ -138,17 +164,30 @@ impl Transpiler { matches!(&expr.kind, ExprKind::Literal(Literal::Unit)) || matches!( &expr.kind, - ExprKind::Call { func, .. } if matches!(&func.kind, ExprKind::Identifier(name) if name == "println" || name == "print") + ExprKind::Call { func, .. } if matches!(&func.kind, ExprKind::Identifier(name) if Self::is_print_name(name)) ) || matches!( &expr.kind, - ExprKind::MacroInvocation { name, .. } if name == "println" || name == "print" + ExprKind::MacroInvocation { name, .. } if Self::is_print_name(name) ) || matches!(&expr.kind, ExprKind::Assign { .. }) + // STRRECV-1: loops without a break value and compound assignment + || matches!( + &expr.kind, + ExprKind::For { .. } + | ExprKind::While { .. } + | ExprKind::WhileLet { .. } + | ExprKind::CompoundAssign { .. } + ) || matches!(&expr.kind, ExprKind::Return { value: None }) || matches!(&expr.kind, ExprKind::Return { value: Some(_) }) // Return with value is still unit for main } + /// PRINTLNFMT-1: the print builtins, all unit-valued (stdout and stderr). + fn is_print_name(name: &str) -> bool { + matches!(name, "println" | "print" | "eprintln" | "eprint") + } + /// BOOK-COMPAT-015: Generate main body that prints the last expression fn generate_main_body_with_print(&self, body: &Expr, is_async: bool) -> Result { match &body.kind { @@ -192,6 +231,152 @@ impl Transpiler { } } +impl Transpiler { + /// A `main` that awaits gets a `block_on` body and a sync signature; others pass through. + /// Complexity: 2 + fn adapt_awaiting_main( + &self, + name: &str, + body: &Expr, + body_tokens: TokenStream, + is_async: bool, + return_type: Option<&Type>, + ) -> Result<(TokenStream, bool)> { + if name == "main" && (is_async || Self::tokens_contain_await(&body_tokens)) { + Ok((self.generate_block_on_main_body(body, return_type)?, false)) + } else { + Ok((body_tokens, is_async)) + } + } + + /// True when the emitted Rust uses the `await` keyword (only legal in an async context). + /// Complexity: 3 + fn tokens_contain_await(tokens: &TokenStream) -> bool { + tokens.clone().into_iter().any(|tt| match tt { + proc_macro2::TokenTree::Ident(ident) => ident == "await", + proc_macro2::TokenTree::Group(group) => Self::tokens_contain_await(&group.stream()), + _ => false, + }) + } + + /// Body of a `main` that awaits: the original body runs as an `async move` block driven + /// by a thread-parking `block_on` (std only, no unsafe); a non-unit tail is printed. + /// Complexity: 2 + fn generate_block_on_main_body( + &self, + body: &Expr, + return_type: Option<&Type>, + ) -> Result { + let inner = self.generate_body_tokens(body, true)?; + let tail = match self.declared_non_unit_type(return_type)? { + // RHLGA-1: a declared return type is main's value; the typed binding pins the + // async block's output so `?` inside it resolves + Some(ret) => quote! { + let __ruchy_main_value: #ret = __ruchy_block_on(async move { #inner }); + __ruchy_main_value + }, + None => { + let run = quote! { __ruchy_block_on(async move { #inner }) }; + if self.has_non_unit_last_expr(body) { + quote! { let __ruchy_main_value = #run; println!("{:?}", __ruchy_main_value); } + } else { + quote! { #run; } + } + } + }; + let block_on = Self::block_on_fn_tokens(); + Ok(quote! { + { + #block_on + #tail + } + }) + } + + /// Tokens of a declared return type other than `()`. + fn declared_non_unit_type(&self, return_type: Option<&Type>) -> Result> { + let Some(ty) = return_type else { + return Ok(None); + }; + let tokens = self.transpile_type(ty)?; + Ok((tokens.to_string().replace(' ', "") != "()").then_some(tokens)) + } + + /// A thread-parking `block_on` (std only, no unsafe) for the awaiting `main`. + fn block_on_fn_tokens() -> TokenStream { + quote! { + fn __ruchy_block_on(fut: F) -> F::Output { + struct ThreadWaker(::std::thread::Thread); + impl ::std::task::Wake for ThreadWaker { + fn wake(self: ::std::sync::Arc) { + self.0.unpark(); + } + } + let waker = ::std::task::Waker::from(::std::sync::Arc::new(ThreadWaker( + ::std::thread::current(), + ))); + let mut cx = ::std::task::Context::from_waker(&waker); + let mut fut = ::std::pin::pin!(fut); + loop { + if let ::std::task::Poll::Ready(value) = fut.as_mut().poll(&mut cx) { + return value; + } + ::std::thread::park(); + } + } + } + } +} + +/// CLOSUREUNIT-1: the last expression of `body` calls a closure bound by a +/// `let` earlier in `body` whose body is unit (the latest binding of the +/// name wins, so a shadowing value closure is still printed). +fn tail_calls_unit_closure(body: &Expr) -> bool { + let mut closures = std::collections::HashMap::new(); + tail_with_closures(body, &mut closures) +} + +/// CLOSUREUNIT-1: walk the statement chain of `expr` to its tail, recording +/// in `closures` whether each let-bound name is a unit-bodied closure. +fn tail_with_closures<'a>( + expr: &'a Expr, + closures: &mut std::collections::HashMap<&'a str, bool>, +) -> bool { + match &expr.kind { + ExprKind::Block(exprs) => { + let Some((last, init)) = exprs.split_last() else { + return false; + }; + init.iter() + .for_each(|e| record_closure_binding(e, closures)); + tail_with_closures(last, closures) + } + ExprKind::Let { body, .. } => { + record_closure_binding(expr, closures); + tail_with_closures(body, closures) + } + ExprKind::Call { func, .. } => matches!( + &func.kind, + ExprKind::Identifier(name) if closures.get(name.as_str()) == Some(&true) + ), + _ => false, + } +} + +/// CLOSUREUNIT-1: record whether the `let` `expr` binds a unit-bodied closure. +fn record_closure_binding<'a>( + expr: &'a Expr, + closures: &mut std::collections::HashMap<&'a str, bool>, +) { + if let ExprKind::Let { name, value, .. } = &expr.kind { + let is_unit_closure = matches!( + &value.kind, + ExprKind::Lambda { body, .. } if super::function_analysis::is_void_expression(body) + ); + closures.insert(name.as_str(), is_unit_closure); + } +} + #[cfg(test)] mod tests { use super::*; @@ -201,6 +386,54 @@ mod tests { Transpiler::new() } + /// RHLGA-1: the emitted `block_on` parks until woken. The future is Pending on its + /// first poll and is woken from another thread, so the park/wake path runs. + #[test] + fn test_rhlga1_block_on_parks_until_a_pending_future_is_woken() { + let block_on = Transpiler::block_on_fn_tokens(); + let program = format!( + "{block_on}\n{}", + r#" +struct WakeLater { polls: u32 } +impl std::future::Future for WakeLater { + type Output = u32; + fn poll(mut self: std::pin::Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> std::task::Poll { + self.polls += 1; + if self.polls > 1 { + return std::task::Poll::Ready(self.polls); + } + let waker = cx.waker().clone(); + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(20)); + waker.wake(); + }); + std::task::Poll::Pending + } +} +fn main() { + println!("{}", __ruchy_block_on(WakeLater { polls: 0 })); +} +"# + ); + let dir = std::env::temp_dir().join(format!("rhlga1_block_on_{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("scratch dir"); + let (src, bin) = (dir.join("main.rs"), dir.join("main")); + std::fs::write(&src, program).expect("write rust"); + let out = std::process::Command::new("rustc") + .args(["--edition", "2021", "-A", "warnings", "-o"]) + .arg(&bin) + .arg(&src) + .output() + .expect("rustc runs"); + assert!( + out.status.success(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + let run = std::process::Command::new(&bin).output().expect("runs"); + assert_eq!(String::from_utf8_lossy(&run.stdout), "2\n"); + } + #[test] fn test_transpile_simple_function() { let mut transpiler = create_transpiler(); diff --git a/src/backend/transpiler/lambda_transpiler.rs b/src/backend/transpiler/lambda_transpiler.rs index 796df146f..1bc755ae3 100644 --- a/src/backend/transpiler/lambda_transpiler.rs +++ b/src/backend/transpiler/lambda_transpiler.rs @@ -19,7 +19,21 @@ impl Transpiler { params: &[Param], body: &Expr, ) -> Result { - let body_tokens = self.transpile_expr(body)?; + self.transpile_named_lambda(None, params, body) + } + + /// PRINTPARAM-1: a closure bound by `let = |..| ..`; its untyped + /// parameters take the call-site types of `name` for print placeholders. + /// Complexity: 3 + pub(crate) fn transpile_named_lambda( + &self, + name: Option<&str>, + params: &[Param], + body: &Expr, + ) -> Result { + // PRINTSTRSCOPE-1: parameters shadow outer string records. + let body_tokens = + self.with_closure_param_scope(name, params, || self.transpile_expr(body))?; if params.is_empty() { return Ok(quote! { move || #body_tokens }); @@ -57,7 +71,8 @@ impl Transpiler { params: &[Param], body: &Expr, ) -> Result { - let body_tokens = self.transpile_expr(body)?; + // PRINTSTRSCOPE-1: parameters shadow outer string records. + let body_tokens = self.with_param_scope(params, || self.transpile_expr(body))?; if params.is_empty() { return Ok(quote! { move || async move { #body_tokens } }); diff --git a/src/backend/transpiler/lifetime_helpers.rs b/src/backend/transpiler/lifetime_helpers.rs index 5e846625d..1c310a20b 100644 --- a/src/backend/transpiler/lifetime_helpers.rs +++ b/src/backend/transpiler/lifetime_helpers.rs @@ -154,6 +154,11 @@ impl Transpiler { return Ok(quote! { -> impl Fn(i32) -> i32 }); } + // LENRET-1: a `len()` / `count()` tail is a `usize` + if super::return_type_helpers::returns_usize(body) { + return Ok(quote! { -> usize }); + } + if super::function_analysis::looks_like_numeric_function(name) { return Ok(quote! { -> i32 }); } diff --git a/src/backend/transpiler/mod.rs b/src/backend/transpiler/mod.rs index 96363d21d..e55a10266 100644 --- a/src/backend/transpiler/mod.rs +++ b/src/backend/transpiler/mod.rs @@ -177,6 +177,9 @@ pub struct Transpiler { /// /// Maps function names to their parameter types for proper type conversion. pub function_signatures: std::collections::HashMap, + /// MAINUNIT-1: user functions with no declared return type whose body is + /// void, so a call to one yields `()` (not printed as `main`'s tail). + pub unit_functions: std::collections::HashSet, /// Module names that have been imported/defined (Issue #103). /// /// Tracks module identifiers so field access can use :: syntax for module paths. @@ -227,6 +230,9 @@ pub struct Transpiler { /// Maps function name to a vector of inferred argument types from call sites. /// Used when function parameters have no explicit type to infer types from usage. pub call_site_arg_types: std::cell::RefCell>>, + /// NESTPUSHLIT-1: bindings whose inner list literals were emitted as + /// `vec![..]` (NESTARRVEC-1); an array literal pushed into one is too. + pub inner_vec_lists: std::cell::RefCell>, } impl Default for Transpiler { fn default() -> Self { @@ -240,6 +246,7 @@ impl Clone for Transpiler { in_loop_context: std::cell::Cell::new(self.in_loop_context.get()), mutable_vars: self.mutable_vars.clone(), function_signatures: self.function_signatures.clone(), + unit_functions: self.unit_functions.clone(), module_names: self.module_names.clone(), string_vars: std::cell::RefCell::new(self.string_vars.borrow().clone()), current_function_return_type: std::cell::RefCell::new( @@ -262,6 +269,7 @@ impl Clone for Transpiler { current_struct_name: std::cell::RefCell::new(self.current_struct_name.borrow().clone()), auto_boxed_fields: std::cell::RefCell::new(self.auto_boxed_fields.borrow().clone()), call_site_arg_types: std::cell::RefCell::new(self.call_site_arg_types.borrow().clone()), + inner_vec_lists: std::cell::RefCell::new(self.inner_vec_lists.borrow().clone()), } } } @@ -282,6 +290,7 @@ impl Transpiler { in_loop_context: std::cell::Cell::new(false), mutable_vars: std::collections::HashSet::new(), function_signatures: std::collections::HashMap::new(), + unit_functions: std::collections::HashSet::new(), module_names: std::collections::HashSet::new(), string_vars: std::cell::RefCell::new(std::collections::HashSet::new()), current_function_return_type: std::cell::RefCell::new(None), @@ -292,6 +301,7 @@ impl Transpiler { current_struct_name: std::cell::RefCell::new(None), auto_boxed_fields: std::cell::RefCell::new(std::collections::HashMap::new()), call_site_arg_types: std::cell::RefCell::new(std::collections::HashMap::new()), + inner_vec_lists: std::cell::RefCell::new(std::collections::HashSet::new()), } } // EXTREME TDD Round 64: generate_value_printing_tokens moved to print_helpers.rs diff --git a/src/backend/transpiler/mutation_detection.rs b/src/backend/transpiler/mutation_detection.rs index ab32948cd..a335d8236 100644 --- a/src/backend/transpiler/mutation_detection.rs +++ b/src/backend/transpiler/mutation_detection.rs @@ -1,16 +1,172 @@ //! Mutation detection for transpiler //! //! This module provides functions to detect if variables are mutated -//! (reassigned or modified) within expression trees. +//! (reassigned or modified) within expression trees, and whether a method +//! body mutates `self` (for `&mut self` receiver inference). + +use crate::frontend::ast::{Expr, ExprKind, UnaryOp}; +use std::collections::HashSet; + +/// Std methods that take `&mut self`. A call of one of these on a field of +/// `self` (`self.items.push(x)`, `self.buf.push_str(s)`, `self.map.entry(k)`) +/// mutates `self`. The list is deliberately closed: a method not listed here +/// is treated as non-mutating unless it is a mutating method of the same impl. +/// It is a superset of [`VEC_ONLY_METHODS`] and of the interpreter's in-place +/// array methods (`runtime::eval_array::is_in_place_array_method`). +pub const MUTATING_STD_METHODS: &[&str] = &[ + "push", + "pop", + "insert", + "remove", + "clear", + "extend", + "extend_from_slice", + "append", + "truncate", + "resize", + "sort", + "sort_by", + "reverse", + "dedup", + "retain", + "drain", + "push_str", + "swap", + "entry", +]; + +/// LETVEC-1: methods that exist on `Vec` but not on a fixed-size array. A +/// `let`-bound array literal that is the receiver of one of these must be +/// emitted as `vec![..]`. `sort`/`reverse` work on arrays and are not listed. +pub const VEC_ONLY_METHODS: &[&str] = &[ + "push", + "pop", + "extend", + "insert", + "remove", + "clear", + "truncate", + "append", + "dedup", + "retain", + "drain", + "resize", + "extend_from_slice", +]; + +/// Which receiver of a [`VEC_ONLY_METHODS`] call counts as growing `name`. +type GrowCall = fn(&str, &Expr) -> bool; + +/// LETVEC-1: true when the variable `name` is the receiver of a +/// [`VEC_ONLY_METHODS`] call in `expr` that refers to this binding of `name`. +/// +/// The scan follows scope: a `let name = …` ends it (its value still refers +/// to the outer binding, its body and the block statements after it do not), +/// and a closure or nested function with a parameter `name` is skipped. +pub fn is_grown_as_vec(name: &str, expr: &Expr) -> bool { + grown_by(name, expr, is_vec_only_call_on) +} -use crate::frontend::ast::{Expr, ExprKind}; +/// NESTARRVEC-1: like [`is_grown_as_vec`], but for an element of `name`: true +/// when `name[i]` is the receiver of a [`VEC_ONLY_METHODS`] call in `expr`. +pub fn is_elem_grown_as_vec(name: &str, expr: &Expr) -> bool { + grown_by(name, expr, is_vec_only_call_on_elem) +} + +/// The scope-following scan of [`is_grown_as_vec`] with the call test `is_call`. +fn grown_by(name: &str, expr: &Expr, is_call: GrowCall) -> bool { + if is_call(name, expr) { + return true; + } + match &expr.kind { + ExprKind::Block(exprs) => grown_in_statements_by(name, exprs, is_call), + ExprKind::Let { + name: bound, + value, + else_block, + .. + } if bound == name => { + grown_by(name, value, is_call) + || else_block + .as_deref() + .is_some_and(|e| grown_by(name, e, is_call)) + } + _ if binds_param(name, expr) => false, + _ => sub_expressions(expr) + .into_iter() + .any(|e| grown_by(name, e, is_call)), + } +} + +/// A closure or nested function with a parameter called `name`. +fn binds_param(name: &str, expr: &Expr) -> bool { + match &expr.kind { + ExprKind::Lambda { params, .. } | ExprKind::Function { params, .. } => { + params.iter().any(|p| p.name() == name) + } + ExprKind::AsyncLambda { params, .. } => params.iter().any(|p| p == name), + _ => false, + } +} + +/// LETVEC-1: [`is_grown_as_vec`] over a statement sequence: the statements +/// are scanned in order until one of them re-binds `name` with a `let`. +pub fn is_grown_in_statements(name: &str, statements: &[Expr]) -> bool { + grown_in_statements_by(name, statements, is_vec_only_call_on) +} + +/// NESTARRVEC-1: [`is_elem_grown_as_vec`] over a statement sequence. +pub fn is_elem_grown_in_statements(name: &str, statements: &[Expr]) -> bool { + grown_in_statements_by(name, statements, is_vec_only_call_on_elem) +} + +/// The statement scan of [`is_grown_in_statements`] with the call test `is_call`. +fn grown_in_statements_by(name: &str, statements: &[Expr], is_call: GrowCall) -> bool { + for statement in statements { + if grown_by(name, statement, is_call) { + return true; + } + if matches!(&statement.kind, ExprKind::Let { name: bound, .. } if bound == name) { + return false; + } + } + false +} + +/// `name.(..)` with `m` a [`VEC_ONLY_METHODS`] method. +fn is_vec_only_call_on(name: &str, expr: &Expr) -> bool { + let ExprKind::MethodCall { + receiver, method, .. + } = &expr.kind + else { + return false; + }; + matches!(&receiver.kind, ExprKind::Identifier(root) if root == name) + && VEC_ONLY_METHODS.contains(&method.as_str()) +} + +/// NESTARRVEC-1: `name[i].(..)` with `m` a [`VEC_ONLY_METHODS`] method. +fn is_vec_only_call_on_elem(name: &str, expr: &Expr) -> bool { + let ExprKind::MethodCall { + receiver, method, .. + } = &expr.kind + else { + return false; + }; + let ExprKind::IndexAccess { object, .. } = &receiver.kind else { + return false; + }; + matches!(&object.kind, ExprKind::Identifier(root) if root == name) + && VEC_ONLY_METHODS.contains(&method.as_str()) +} /// Checks if a variable is mutated (reassigned or modified) in an expression tree /// -/// This function traverses the AST recursively to detect: -/// - Direct assignments (`x = value`) -/// - Compound assignments (`x += 1`, `x -= 1`, etc.) -/// - Increment/decrement operations (`x++`, `++x`, `x--`, `--x`) +/// This function traverses the AST recursively to detect writes through a +/// place rooted at the variable: +/// - Direct assignments (`x = value`, `x.a.b = value`, `x.v[i] = value`) +/// - Compound assignments (`x += 1`, `x.f -= 1`, etc.) +/// - Increment/decrement operations (`x++`, `++x`, `x.f--`, `--x.f`) /// /// # Examples /// ```ignore @@ -19,110 +175,357 @@ use crate::frontend::ast::{Expr, ExprKind}; /// assert!(is_variable_mutated("x", &expr)); /// ``` pub fn is_variable_mutated(name: &str, expr: &Expr) -> bool { - match &expr.kind { - // Direct assignment to the variable - ExprKind::Assign { target, value: _ } => { - if let ExprKind::Identifier(var_name) = &target.kind { - if var_name == name { - return true; - } - } - // TRANSPILER-METHOD-SELF-001 FIX: Detect field mutation (self.field = value) - if let ExprKind::FieldAccess { object, .. } = &target.kind { - if let ExprKind::Identifier(var_name) = &object.kind { - if var_name == name { - return true; - } - } - } - false + any_expr(expr, &|e| writes_place_rooted_at(name, e)) +} + +/// True when a method body mutates `self`: a write through a place rooted at +/// `self`, a [`MUTATING_STD_METHODS`] call on a field of `self`, or a call +/// `self.m(..)` where `m` is in `mutating_methods` (methods of the same impl +/// already known to mutate `self`). +pub fn is_self_mutated(body: &Expr, mutating_methods: &HashSet) -> bool { + any_expr(body, &|e| { + writes_place_rooted_at("self", e) || is_self_mutating_call(e, mutating_methods) + }) +} + +/// Fixpoint over the methods of one impl/trait: the set of method names that +/// mutate `self`, directly or by calling another mutating method of the set. +/// `seed` holds methods known to mutate up front (declared `&mut self`); +/// `candidates` are the `(name, body)` pairs whose receiver is inferred. +pub fn mutating_self_methods( + candidates: &[(&str, &Expr)], + seed: HashSet, +) -> HashSet { + let mut mutating = seed; + loop { + let newly: Vec = candidates + .iter() + .filter(|(name, body)| !mutating.contains(*name) && is_self_mutated(body, &mutating)) + .map(|(name, _)| (*name).to_string()) + .collect(); + if newly.is_empty() { + return mutating; } - // Compound assignment (+=, -=, etc.) - ExprKind::CompoundAssign { - target, value: _, .. - } => { - if let ExprKind::Identifier(var_name) = &target.kind { - if var_name == name { - return true; - } - } - // TRANSPILER-METHOD-SELF-001 FIX: Detect field compound assignment (self.field += value) - if let ExprKind::FieldAccess { object, .. } = &target.kind { - if let ExprKind::Identifier(var_name) = &object.kind { - if var_name == name { - return true; - } - } - } - false + mutating.extend(newly); + } +} + +/// `self.(..)` with `m` a mutating sibling, or `self..(..)` with +/// `m` a mutating std method. +fn is_self_mutating_call(expr: &Expr, mutating_methods: &HashSet) -> bool { + let ExprKind::MethodCall { + receiver, method, .. + } = &expr.kind + else { + return false; + }; + match &receiver.kind { + ExprKind::Identifier(root) => root == "self" && mutating_methods.contains(method), + _ => { + place_root(receiver) == Some("self") && MUTATING_STD_METHODS.contains(&method.as_str()) } - // Pre/Post increment/decrement - ExprKind::PreIncrement { target } + } +} + +/// Assignment, compound assignment or `++`/`--` whose target place is rooted +/// at `name`, or a `&mut ` borrow (passed to a call). +fn writes_place_rooted_at(name: &str, expr: &Expr) -> bool { + match &expr.kind { + ExprKind::Unary { + op: UnaryOp::MutableReference, + operand, + } => place_root(operand) == Some(name), + ExprKind::Assign { target, .. } + | ExprKind::CompoundAssign { target, .. } + | ExprKind::PreIncrement { target } | ExprKind::PostIncrement { target } | ExprKind::PreDecrement { target } - | ExprKind::PostDecrement { target } => { - if let ExprKind::Identifier(var_name) = &target.kind { - if var_name == name { - return true; - } - } - false + | ExprKind::PostDecrement { target } => place_root(target) == Some(name), + _ => false, + } +} + +/// Root variable of a place expression: `x`, `x.a.b`, `x[i]`, `x.v[i].c` all give `x`. +fn place_root(expr: &Expr) -> Option<&str> { + match &expr.kind { + ExprKind::Identifier(name) => Some(name.as_str()), + ExprKind::FieldAccess { object, .. } | ExprKind::IndexAccess { object, .. } => { + place_root(object) } - // Check in blocks - ExprKind::Block(exprs) => exprs.iter().any(|e| is_variable_mutated(name, e)), - // Check in if branches + _ => None, + } +} + +/// True when `pred` holds for `expr` or any expression nested in it. +fn any_expr(expr: &Expr, pred: &dyn Fn(&Expr) -> bool) -> bool { + pred(expr) || sub_expressions(expr).into_iter().any(|e| any_expr(e, pred)) +} + +/// The direct sub-expressions searched for mutations. +/// +/// Every expression kind that holds a sub-expression evaluated as part of the +/// enclosing body is walked. The kinds are split across small category +/// helpers chained through their `_ =>` fallbacks: this function (sequences, +/// control flow, single-child wrappers), [`binding_sub_expressions`] (let, +/// try/catch, effect handlers, pipelines, two-operand forms), +/// [`call_sub_expressions`] (calls, actor asks, slices, data frame +/// operations) and [`literal_sub_expressions`] (struct, object, string, +/// comprehension and data frame literals). +/// +/// Deliberately NOT walked: +/// - the item definitions `Module`, `Export`, `ExportDefault`, `Struct`, +/// `TupleStruct`, `Class`, `Enum`, `Trait`, `Impl`, `Actor`, `Effect` and +/// `Extension`: they are top-level items whose bodies are other methods +/// with their own receivers, so a `self` write inside one is not a write to +/// the enclosing method's `self`; +/// - the leaves `Literal`, `Identifier`, `QualifiedName`, `None`, +/// `Continue`, `Command`, `ModuleDeclaration`, `Import`, `ImportAll`, +/// `ImportDefault`, `ExportList`, `ReExport` and `TypeAlias`, which hold no +/// sub-expression. +fn sub_expressions(expr: &Expr) -> Vec<&Expr> { + match &expr.kind { + ExprKind::Block(exprs) + | ExprKind::List(exprs) + | ExprKind::Tuple(exprs) + | ExprKind::Set(exprs) + | ExprKind::InfraBlock { body: exprs } + | ExprKind::Macro { args: exprs, .. } + | ExprKind::MacroInvocation { args: exprs, .. } => exprs.iter().collect(), ExprKind::If { condition, then_branch, else_branch, - } => { - is_variable_mutated(name, condition) - || is_variable_mutated(name, then_branch) - || else_branch - .as_ref() - .is_some_and(|e| is_variable_mutated(name, e)) + } => with_optional(vec![condition, then_branch], else_branch.as_deref()), + ExprKind::IfLet { + expr, + then_branch, + else_branch, + .. + } => with_optional(vec![expr, then_branch], else_branch.as_deref()), + ExprKind::Match { expr, arms } => std::iter::once(&**expr) + .chain( + arms.iter() + .flat_map(|arm| arm.guard.as_deref().into_iter().chain([&*arm.body])), + ) + .collect(), + ExprKind::Return { value } | ExprKind::Break { value, .. } | ExprKind::Yield { value } => { + value.as_deref().into_iter().collect() } - // Check in while loops - ExprKind::While { - condition, body, .. - } => is_variable_mutated(name, condition) || is_variable_mutated(name, body), - // Check in for loops - ExprKind::For { body, .. } => is_variable_mutated(name, body), - // Check in match expressions - ExprKind::Match { expr, arms } => { - is_variable_mutated(name, expr) - || arms.iter().any(|arm| is_variable_mutated(name, &arm.body)) + _ => single_sub_expression(expr) + .map(|e| vec![e]) + .unwrap_or_else(|| binding_sub_expressions(expr)), + } +} + +/// The one sub-expression of a single-child form (wrappers, closures, `++`/`--`). +fn single_sub_expression(expr: &Expr) -> Option<&Expr> { + match &expr.kind { + ExprKind::Loop { body: e, .. } + | ExprKind::Function { body: e, .. } + | ExprKind::Lambda { body: e, .. } + | ExprKind::AsyncLambda { body: e, .. } + | ExprKind::AsyncBlock { body: e } + | ExprKind::Lazy { expr: e } + | ExprKind::Unary { operand: e, .. } + | ExprKind::Try { expr: e } + | ExprKind::Await { expr: e } + | ExprKind::FieldAccess { object: e, .. } + | ExprKind::OptionalFieldAccess { object: e, .. } + | ExprKind::Throw { expr: e } + | ExprKind::Ok { value: e } + | ExprKind::Err { error: e } + | ExprKind::Some { value: e } + | ExprKind::TypeCast { expr: e, .. } + | ExprKind::Spawn { actor: e } + | ExprKind::Spread { expr: e } + | ExprKind::Signal { initial_value: e } => Some(&**e), + // The target place of `++`/`--` is evaluated too (`v[{ self.n += 1; 0 }]++`). + ExprKind::PreIncrement { target: e } + | ExprKind::PostIncrement { target: e } + | ExprKind::PreDecrement { target: e } + | ExprKind::PostDecrement { target: e } => Some(&**e), + _ => None, + } +} + +/// Sub-expressions of the binding, handler and two-operand forms. +fn binding_sub_expressions(expr: &Expr) -> Vec<&Expr> { + match &expr.kind { + ExprKind::Let { + value, + body, + else_block, + .. } - // Check in nested let expressions - ExprKind::Let { body, .. } | ExprKind::LetPattern { body, .. } => { - is_variable_mutated(name, body) + | ExprKind::LetPattern { + value, + body, + else_block, + .. + } => with_optional(vec![value, body], else_block.as_deref()), + ExprKind::TryCatch { + try_block, + catch_clauses, + finally_block, + } => std::iter::once(&**try_block) + .chain(catch_clauses.iter().map(|c| &*c.body)) + .chain(finally_block.as_deref()) + .collect(), + ExprKind::Handle { expr, handlers } => std::iter::once(&**expr) + .chain(handlers.iter().map(|h| &*h.body)) + .collect(), + ExprKind::Pipeline { expr, stages } => std::iter::once(&**expr) + .chain(stages.iter().map(|s| &*s.op)) + .collect(), + ExprKind::Ternary { + condition, + true_expr, + false_expr, + } => vec![&**condition, &**true_expr, &**false_expr], + // An assignment evaluates its target place as well as its value, so a + // mutation nested in either is found (e.g. `v[{ self.n += 1; 0 }] = 2`). + ExprKind::Assign { + target: a, + value: b, } - // Check in function bodies - ExprKind::Function { body, .. } => is_variable_mutated(name, body), - // Check in lambda bodies - ExprKind::Lambda { body, .. } => is_variable_mutated(name, body), - // Check binary operations - ExprKind::Binary { left, right, .. } => { - is_variable_mutated(name, left) || is_variable_mutated(name, right) + | ExprKind::CompoundAssign { + target: a, + value: b, + .. } - // Check unary operations - ExprKind::Unary { operand, .. } => is_variable_mutated(name, operand), - // Check function/method calls - ExprKind::Call { func, args } => { - is_variable_mutated(name, func) || args.iter().any(|a| is_variable_mutated(name, a)) + | ExprKind::Binary { + left: a, right: b, .. } - ExprKind::MethodCall { receiver, args, .. } => { - is_variable_mutated(name, receiver) || args.iter().any(|a| is_variable_mutated(name, a)) + | ExprKind::IndexAccess { + object: a, + index: b, } - // Other expressions don't contain mutations - _ => false, + | ExprKind::While { + condition: a, + body: b, + .. + } + | ExprKind::WhileLet { + expr: a, body: b, .. + } + | ExprKind::For { + iter: a, body: b, .. + } + | ExprKind::Range { + start: a, end: b, .. + } + | ExprKind::ArrayInit { value: a, size: b } + | ExprKind::VecRepeat { value: a, count: b } + | ExprKind::Send { + actor: a, + message: b, + } + | ExprKind::ActorSend { + actor: a, + message: b, + } + | ExprKind::ActorQuery { + actor: a, + message: b, + } => vec![&**a, &**b], + _ => call_sub_expressions(expr), + } +} + +/// Sub-expressions of calls, actor asks, slices and data frame operations. +fn call_sub_expressions(expr: &Expr) -> Vec<&Expr> { + match &expr.kind { + ExprKind::Call { func: r, args } + | ExprKind::MethodCall { + receiver: r, args, .. + } + | ExprKind::OptionalMethodCall { + receiver: r, args, .. + } => std::iter::once(&**r).chain(args).collect(), + ExprKind::Ask { + actor, + message, + timeout, + } => with_optional(vec![actor, message], timeout.as_deref()), + ExprKind::Slice { object, start, end } => std::iter::once(&**object) + .chain(start.as_deref()) + .chain(end.as_deref()) + .collect(), + ExprKind::DataFrameOperation { source, operation } => std::iter::once(&**source) + .chain(data_frame_op_expr(operation)) + .collect(), + _ => literal_sub_expressions(expr), + } +} + +/// The expression argument of a data frame operation (`filter(pred)`, `join(other)`). +fn data_frame_op_expr(op: &crate::frontend::ast::DataFrameOp) -> Option<&Expr> { + use crate::frontend::ast::DataFrameOp; + match op { + DataFrameOp::Filter(e) | DataFrameOp::Join { other: e, .. } => Some(&**e), + _ => None, + } +} + +/// Sub-expressions of struct, object, interpolated-string, comprehension and +/// data frame literals. +fn literal_sub_expressions(expr: &Expr) -> Vec<&Expr> { + match &expr.kind { + ExprKind::StructLiteral { fields, base, .. } => fields + .iter() + .map(|(_, e)| e) + .chain(base.as_deref()) + .collect(), + ExprKind::ObjectLiteral { fields } => fields + .iter() + .map(|f| match f { + crate::frontend::ast::ObjectField::KeyValue { value, .. } => value, + crate::frontend::ast::ObjectField::Spread { expr } => expr, + }) + .collect(), + ExprKind::StringInterpolation { parts } => parts + .iter() + .filter_map(|p| match p { + crate::frontend::ast::StringPart::Text(_) => None, + crate::frontend::ast::StringPart::Expr(e) + | crate::frontend::ast::StringPart::ExprWithFormat { expr: e, .. } => Some(&**e), + }) + .collect(), + ExprKind::ListComprehension { element, clauses } + | ExprKind::SetComprehension { element, clauses } => std::iter::once(&**element) + .chain(clause_exprs(clauses)) + .collect(), + ExprKind::DictComprehension { + key, + value, + clauses, + } => [&**key, &**value] + .into_iter() + .chain(clause_exprs(clauses)) + .collect(), + ExprKind::DataFrame { columns } => columns.iter().flat_map(|c| &c.values).collect(), + _ => Vec::new(), } } +/// The iterables and filter conditions of comprehension clauses. +fn clause_exprs( + clauses: &[crate::frontend::ast::ComprehensionClause], +) -> impl Iterator { + clauses + .iter() + .flat_map(|c| std::iter::once(&*c.iterable).chain(c.condition.as_deref())) +} + +/// `required` (as plain references) followed by `optional` when present. +fn with_optional<'a>(required: Vec<&'a Box>, optional: Option<&'a Expr>) -> Vec<&'a Expr> { + required.into_iter().map(|e| &**e).chain(optional).collect() +} + #[cfg(test)] mod tests { use super::*; - use crate::frontend::ast::{BinaryOp, Literal, MatchArm, Pattern, Span, UnaryOp}; + use crate::frontend::ast::{BinaryOp, Literal, MatchArm, Pattern, Span}; // ==================== Test Helpers ==================== @@ -770,11 +1173,602 @@ mod tests { assert!(is_variable_mutated("x", &nested_compound)); } + #[test] + fn test_self_mutation_nested_in_an_index_target() { + // v[{ self.n += 1 }] = 2 — the index evaluates a mutation of `self`. + let self_n = make_expr(ExprKind::FieldAccess { + object: Box::new(ident("self")), + field: "n".to_string(), + }); + let bump = compound_assign(self_n, int_lit(1)); + let place = make_expr(ExprKind::IndexAccess { + object: Box::new(ident("v")), + index: Box::new(bump), + }); + let stmt = assign(place, int_lit(2)); + assert!(is_variable_mutated("self", &stmt)); + } + + #[test] + fn test_self_mutation_nested_in_value_forms() { + let bump = || { + let self_n = make_expr(ExprKind::FieldAccess { + object: Box::new(ident("self")), + field: "n".to_string(), + }); + compound_assign(self_n, int_lit(1)) + }; + let forms = vec![ + make_expr(ExprKind::Some { + value: Box::new(bump()), + }), + make_expr(ExprKind::TypeCast { + expr: Box::new(bump()), + target_type: "i64".to_string(), + }), + make_expr(ExprKind::Ternary { + condition: Box::new(int_lit(1)), + true_expr: Box::new(bump()), + false_expr: Box::new(int_lit(0)), + }), + make_expr(ExprKind::Range { + start: Box::new(bump()), + end: Box::new(int_lit(3)), + inclusive: false, + }), + make_expr(ExprKind::StructLiteral { + name: "P".to_string(), + fields: vec![("x".to_string(), bump())], + base: None, + }), + ]; + for form in &forms { + assert!( + is_variable_mutated("self", form), + "missed in {:?}", + form.kind + ); + } + } + + // ==================== Walk-every-kind table (G2BFA2) ==================== + + /// `self.n += 1`: the mutation every table case nests. + fn bump() -> Expr { + let self_n = make_expr(ExprKind::FieldAccess { + object: Box::new(ident("self")), + field: "n".to_string(), + }); + compound_assign(self_n, int_lit(1)) + } + + fn bx(e: Expr) -> Box { + Box::new(e) + } + + fn clause( + iterable: Expr, + condition: Option, + ) -> crate::frontend::ast::ComprehensionClause { + crate::frontend::ast::ComprehensionClause { + variable: "i".to_string(), + iterable: bx(iterable), + condition: condition.map(bx), + } + } + + /// One case per walked expression kind (and per walked field where a kind + /// holds several), each nesting `self.n += 1`. Deleting any walk arm fails + /// exactly the cases naming that kind. + #[allow(clippy::too_many_lines)] + fn walk_cases() -> Vec<(&'static str, Expr)> { + use crate::frontend::ast::{ + CatchClause, DataFrameColumn, DataFrameOp, EffectHandler, JoinType, ObjectField, + PipelineStage, StringPart, + }; + let e = make_expr; + let wild_arm = |guard: Option, body: Expr| MatchArm { + pattern: Pattern::Wildcard, + guard: guard.map(bx), + body: bx(body), + span: Span::default(), + }; + let let_else = |else_block: Expr| ExprKind::Let { + name: "y".to_string(), + type_annotation: None, + value: bx(int_lit(1)), + body: bx(int_lit(0)), + is_mutable: false, + else_block: Some(bx(else_block)), + }; + let try_catch = |t: Expr, c: Expr, f: Option| ExprKind::TryCatch { + try_block: bx(t), + catch_clauses: vec![CatchClause { + pattern: Pattern::Wildcard, + body: bx(c), + }], + finally_block: f.map(bx), + }; + vec![ + // ---- walked before commit 0383bc72 + ("Block", block(vec![bump()])), + ("List", e(ExprKind::List(vec![bump()]))), + ("Tuple", e(ExprKind::Tuple(vec![bump()]))), + ( + "Macro", + e(ExprKind::Macro { + name: "sql".into(), + args: vec![bump()], + }), + ), + ("If", if_expr(bool_lit(true), bump(), None)), + ( + "IfLet", + e(ExprKind::IfLet { + pattern: Pattern::Wildcard, + expr: bx(int_lit(1)), + then_branch: bx(bump()), + else_branch: None, + }), + ), + ("While", while_expr(bool_lit(true), bump())), + ( + "WhileLet", + e(ExprKind::WhileLet { + label: None, + pattern: Pattern::Wildcard, + expr: bx(int_lit(1)), + body: bx(bump()), + }), + ), + ("For", for_expr("i", ident("v"), bump())), + ( + "Match arm body", + match_expr(int_lit(1), vec![wild_arm(None, bump())]), + ), + ("Let value", let_expr("y", bump(), int_lit(0))), + ("Binary", binary(int_lit(1), bump())), + ( + "Loop", + e(ExprKind::Loop { + label: None, + body: bx(bump()), + }), + ), + ("Lambda", lambda(vec![], bump())), + ("Unary", unary(bump())), + ("Try", e(ExprKind::Try { expr: bx(bump()) })), + ("Await", e(ExprKind::Await { expr: bx(bump()) })), + ("CompoundAssign value", compound_assign(ident("z"), bump())), + ( + "Return", + e(ExprKind::Return { + value: Some(bx(bump())), + }), + ), + ("Call", call(ident("f"), vec![bump()])), + ("MethodCall", method_call(ident("o"), "m", vec![bump()])), + // ---- arms added in commit 0383bc72 + ("Some", e(ExprKind::Some { value: bx(bump()) })), + ("Ok", e(ExprKind::Ok { value: bx(bump()) })), + ("Err", e(ExprKind::Err { error: bx(bump()) })), + ("Throw", e(ExprKind::Throw { expr: bx(bump()) })), + ( + "TypeCast", + e(ExprKind::TypeCast { + expr: bx(bump()), + target_type: "i64".into(), + }), + ), + ( + "Ternary", + e(ExprKind::Ternary { + condition: bx(bool_lit(true)), + true_expr: bx(int_lit(0)), + false_expr: bx(bump()), + }), + ), + ("Spawn", e(ExprKind::Spawn { actor: bx(bump()) })), + ( + "OptionalFieldAccess", + e(ExprKind::OptionalFieldAccess { + object: bx(bump()), + field: "f".into(), + }), + ), + ( + "Send", + e(ExprKind::Send { + actor: bx(ident("a")), + message: bx(bump()), + }), + ), + ( + "ArrayInit", + e(ExprKind::ArrayInit { + value: bx(int_lit(0)), + size: bx(bump()), + }), + ), + ( + "Range", + e(ExprKind::Range { + start: bx(int_lit(0)), + end: bx(bump()), + inclusive: false, + }), + ), + ( + "Break", + e(ExprKind::Break { + label: None, + value: Some(bx(bump())), + }), + ), + ("Set", e(ExprKind::Set(vec![bump()]))), + ( + "OptionalMethodCall", + e(ExprKind::OptionalMethodCall { + receiver: bx(ident("o")), + method: "m".into(), + args: vec![bump()], + }), + ), + ( + "StructLiteral", + e(ExprKind::StructLiteral { + name: "P".into(), + fields: vec![("x".into(), bump())], + base: None, + }), + ), + ( + "ObjectLiteral", + e(ExprKind::ObjectLiteral { + fields: vec![ObjectField::KeyValue { + key: "k".into(), + value: bump(), + }], + }), + ), + ( + "StringInterpolation", + e(ExprKind::StringInterpolation { + parts: vec![StringPart::Expr(bx(bump()))], + }), + ), + ( + "FieldAccess", + e(ExprKind::FieldAccess { + object: bx(bump()), + field: "f".into(), + }), + ), + ( + "IndexAccess", + e(ExprKind::IndexAccess { + object: bx(ident("v")), + index: bx(bump()), + }), + ), + ( + "Assign target", + assign( + e(ExprKind::IndexAccess { + object: bx(ident("v")), + index: bx(bump()), + }), + int_lit(2), + ), + ), + // ---- kinds the round-3 review found unwalked + ( + "MacroInvocation", + e(ExprKind::MacroInvocation { + name: "println".into(), + args: vec![bump()], + }), + ), + ( + "VecRepeat", + e(ExprKind::VecRepeat { + value: bx(int_lit(0)), + count: bx(bump()), + }), + ), + ("TryCatch try_block", e(try_catch(bump(), int_lit(0), None))), + ( + "TryCatch catch body", + e(try_catch(int_lit(0), bump(), None)), + ), + ( + "TryCatch finally_block", + e(try_catch(int_lit(0), int_lit(0), Some(bump()))), + ), + ("Let else_block", e(let_else(bump()))), + ( + "LetPattern else_block", + e(ExprKind::LetPattern { + pattern: Pattern::Wildcard, + type_annotation: None, + value: bx(int_lit(1)), + body: bx(int_lit(0)), + is_mutable: false, + else_block: Some(bx(bump())), + }), + ), + ( + "MatchArm guard", + match_expr(int_lit(1), vec![wild_arm(Some(bump()), int_lit(0))]), + ), + ("AsyncBlock", e(ExprKind::AsyncBlock { body: bx(bump()) })), + ( + "AsyncLambda", + e(ExprKind::AsyncLambda { + params: vec![], + body: bx(bump()), + }), + ), + ("Lazy", e(ExprKind::Lazy { expr: bx(bump()) })), + ( + "Slice object", + e(ExprKind::Slice { + object: bx(bump()), + start: None, + end: None, + }), + ), + ( + "Slice start", + e(ExprKind::Slice { + object: bx(ident("v")), + start: Some(bx(bump())), + end: None, + }), + ), + ( + "Slice end", + e(ExprKind::Slice { + object: bx(ident("v")), + start: None, + end: Some(bx(bump())), + }), + ), + ("Spread", e(ExprKind::Spread { expr: bx(bump()) })), + ( + "Pipeline expr", + e(ExprKind::Pipeline { + expr: bx(bump()), + stages: vec![], + }), + ), + ( + "Pipeline stage", + e(ExprKind::Pipeline { + expr: bx(ident("v")), + stages: vec![PipelineStage { + op: bx(bump()), + span: Span::default(), + }], + }), + ), + ( + "ListComprehension element", + e(ExprKind::ListComprehension { + element: bx(bump()), + clauses: vec![clause(ident("v"), None)], + }), + ), + ( + "ListComprehension iterable", + e(ExprKind::ListComprehension { + element: bx(ident("i")), + clauses: vec![clause(bump(), None)], + }), + ), + ( + "ListComprehension condition", + e(ExprKind::ListComprehension { + element: bx(ident("i")), + clauses: vec![clause(ident("v"), Some(bump()))], + }), + ), + ( + "SetComprehension", + e(ExprKind::SetComprehension { + element: bx(bump()), + clauses: vec![clause(ident("v"), None)], + }), + ), + ( + "DictComprehension key", + e(ExprKind::DictComprehension { + key: bx(bump()), + value: bx(ident("i")), + clauses: vec![clause(ident("v"), None)], + }), + ), + ( + "DictComprehension value", + e(ExprKind::DictComprehension { + key: bx(ident("i")), + value: bx(bump()), + clauses: vec![clause(ident("v"), None)], + }), + ), + ( + "DictComprehension clause", + e(ExprKind::DictComprehension { + key: bx(ident("i")), + value: bx(ident("i")), + clauses: vec![clause(bump(), None)], + }), + ), + ( + "Ask", + e(ExprKind::Ask { + actor: bx(ident("a")), + message: bx(bump()), + timeout: None, + }), + ), + ( + "Ask timeout", + e(ExprKind::Ask { + actor: bx(ident("a")), + message: bx(ident("m")), + timeout: Some(bx(bump())), + }), + ), + ( + "ActorSend", + e(ExprKind::ActorSend { + actor: bx(ident("a")), + message: bx(bump()), + }), + ), + ( + "ActorQuery", + e(ExprKind::ActorQuery { + actor: bx(ident("a")), + message: bx(bump()), + }), + ), + ( + "Handle expr", + e(ExprKind::Handle { + expr: bx(bump()), + handlers: vec![], + }), + ), + ( + "Handle handler body", + e(ExprKind::Handle { + expr: bx(ident("x")), + handlers: vec![EffectHandler { + operation: "op".into(), + params: vec![], + body: bx(bump()), + }], + }), + ), + ( + "Yield", + e(ExprKind::Yield { + value: Some(bx(bump())), + }), + ), + ( + "Signal", + e(ExprKind::Signal { + initial_value: bx(bump()), + }), + ), + ( + "DataFrameOperation source", + e(ExprKind::DataFrameOperation { + source: bx(bump()), + operation: DataFrameOp::Head(1), + }), + ), + ( + "DataFrameOperation filter", + e(ExprKind::DataFrameOperation { + source: bx(ident("df")), + operation: DataFrameOp::Filter(bx(bump())), + }), + ), + ( + "DataFrameOperation join", + e(ExprKind::DataFrameOperation { + source: bx(ident("df")), + operation: DataFrameOp::Join { + other: bx(bump()), + on: vec![], + how: JoinType::Inner, + }, + }), + ), + ( + "DataFrame column", + e(ExprKind::DataFrame { + columns: vec![DataFrameColumn { + name: "c".into(), + values: vec![bump()], + }], + }), + ), + ("InfraBlock", e(ExprKind::InfraBlock { body: vec![bump()] })), + ( + "PreIncrement target", + pre_increment(e(ExprKind::IndexAccess { + object: bx(ident("v")), + index: bx(bump()), + })), + ), + ( + "PostIncrement target", + post_increment(e(ExprKind::IndexAccess { + object: bx(ident("v")), + index: bx(bump()), + })), + ), + ( + "PreDecrement target", + pre_decrement(e(ExprKind::IndexAccess { + object: bx(ident("v")), + index: bx(bump()), + })), + ), + ( + "PostDecrement target", + post_decrement(e(ExprKind::IndexAccess { + object: bx(ident("v")), + index: bx(bump()), + })), + ), + ] + } + + #[test] + fn test_g2bfa2_self_mutation_found_in_every_walked_kind() { + let missed: Vec<&str> = walk_cases() + .iter() + .filter(|(_, expr)| !is_variable_mutated("self", expr)) + .map(|(kind, _)| *kind) + .collect(); + assert!(missed.is_empty(), "self mutation missed inside: {missed:?}"); + } + + #[test] + fn test_g2bfa2_self_mutation_found_by_is_self_mutated_in_every_walked_kind() { + let none = HashSet::new(); + for (kind, expr) in walk_cases() { + assert!( + is_self_mutated(&expr, &none), + "is_self_mutated missed {kind}" + ); + } + } + + #[test] + fn test_g2bfa2_no_false_positive_without_a_mutation() { + let quiet = make_expr(ExprKind::MacroInvocation { + name: "println".into(), + args: vec![make_expr(ExprKind::FieldAccess { + object: Box::new(ident("self")), + field: "n".to_string(), + })], + }); + assert!(!is_variable_mutated("self", &quiet)); + } + #[test] fn test_assign_nested_value() { let nested_assign = assign(ident("y"), assign(ident("x"), int_lit(1))); - // Note: Assignment in value position doesn't mutate x as target - assert!(!is_variable_mutated("x", &nested_assign)); + // `y = (x = 1)` assigns x: an assignment mutates its target wherever it + // appears (G2BFA2; this test used to pin the narrower walk that missed it). + assert!(is_variable_mutated("x", &nested_assign)); } #[test] @@ -788,7 +1782,29 @@ mod tests { let ret = make_expr(ExprKind::Return { value: Some(Box::new(assign(ident("x"), int_lit(1)))), }); - // Return value is evaluated but doesn't count as mutation - assert!(!is_variable_mutated("x", &ret)); + // `return (x = 1)` assigns x before returning (G2BFA2). + assert!(is_variable_mutated("x", &ret)); + } + + /// RHLGA-1 review F3: every Vec-only method and every in-place array + /// method the interpreter knows is a mutating std method, so a call of + /// one on `self.` infers `&mut self`. + #[test] + fn test_rhlga_1_mutating_std_methods_cover_in_place_tables() { + let in_place = [ + "push", "pop", "insert", "remove", "clear", "extend", "append", + ] + .into_iter() + .chain(["truncate", "sort", "reverse", "dedup", "resize"]) + .chain(["extend_from_slice", "retain", "drain", "sort_by"]); + for method in in_place.chain(VEC_ONLY_METHODS.iter().copied()) { + let is_runtime_in_place = + (0..3).any(|n| crate::runtime::eval_array::is_in_place_array_method(method, n)); + let known = VEC_ONLY_METHODS.contains(&method) || is_runtime_in_place; + assert!( + !known || MUTATING_STD_METHODS.contains(&method), + "{method} is in-place but not in MUTATING_STD_METHODS" + ); + } } } diff --git a/src/backend/transpiler/patterns.rs b/src/backend/transpiler/patterns.rs index 27f2b599c..47c41d949 100644 --- a/src/backend/transpiler/patterns.rs +++ b/src/backend/transpiler/patterns.rs @@ -29,10 +29,13 @@ impl Transpiler { let mut arm_tokens = Vec::new(); for arm in arms { let pattern_tokens = self.transpile_pattern(&arm.pattern)?; - let body_tokens = self.transpile_expr(&arm.body)?; + // PRINTSTRSCOPE-1: arm bindings shadow outer string records. + let body_tokens = + self.with_pattern_scope(&arm.pattern, || self.transpile_expr(&arm.body))?; // Handle pattern guards if present if let Some(guard_expr) = &arm.guard { - let guard_tokens = self.transpile_expr(guard_expr)?; + let guard_tokens = + self.with_pattern_scope(&arm.pattern, || self.transpile_expr(guard_expr))?; arm_tokens.push(quote! { #pattern_tokens if #guard_tokens => #body_tokens }); diff --git a/src/backend/transpiler/print_helpers.rs b/src/backend/transpiler/print_helpers.rs index e3ea32fbc..e9611ef49 100644 --- a/src/backend/transpiler/print_helpers.rs +++ b/src/backend/transpiler/print_helpers.rs @@ -8,8 +8,9 @@ //! //! **EXTREME TDD Round 64**: Extracted from statements.rs and mod.rs for modularization. +use super::pattern_bindings::extract_pattern_bindings; use super::Transpiler; -use crate::frontend::ast::{Expr, ExprKind, Literal}; +use crate::frontend::ast::{Expr, ExprKind, Literal, Param, Pattern, Type, TypeKind}; use anyhow::Result; use proc_macro2::TokenStream; use quote::quote; @@ -91,15 +92,15 @@ impl Transpiler { ) -> Result> { contract_pre_configuration!(); contract_post_configuration!(&"ok"); - if !(base_name == "println" - || base_name == "print" - || base_name == "dbg" - || base_name == "panic") - { + if !matches!( + base_name, + "println" | "print" | "eprintln" | "eprint" | "dbg" | "panic" + ) { return Ok(None); } // Handle single argument with string interpolation - if (base_name == "println" || base_name == "print") && args.len() == 1 { + let is_print = matches!(base_name, "println" | "print" | "eprintln" | "eprint"); + if is_print && args.len() == 1 { if let ExprKind::StringInterpolation { parts } = &args[0].kind { return Ok(Some( self.transpile_print_with_interpolation(base_name, parts)?, @@ -109,8 +110,9 @@ impl Transpiler { if !matches!(&args[0].kind, ExprKind::Literal(Literal::String(_))) { let arg_tokens = self.transpile_expr(&args[0])?; // DEFECT-DICT-DETERMINISM FIX: Use Debug format with BTreeMap (deterministic) - // BTreeMap Debug format is sorted, so {:?} is safe and deterministic - let format_str = "{:?}"; + // BTreeMap Debug format is sorted, so {:?} is safe and deterministic. + // PRINTSTR-1: a known string prints with Display (no quotes). + let format_str = self.value_placeholder(&args[0]); return Ok(Some(quote! { #func_tokens!(#format_str, #arg_tokens) })); } } @@ -182,7 +184,7 @@ impl Transpiler { } else { // Multiple arguments - check if first is format string if let ExprKind::Literal(Literal::String(format_str)) = &args[0].kind { - if format_str.contains("{}") { + if has_format_placeholder(format_str) { // First argument is a format string, rest are values let format_arg = &all_args[0]; let value_args = &all_args[1..]; @@ -191,25 +193,15 @@ impl Transpiler { )) } else { // First argument is regular string, treat all as separate values - let format_parts: Vec<_> = args - .iter() - .map(|arg| match &arg.kind { - ExprKind::Literal(Literal::String(_)) => "{}", - _ => "{:?}", - }) - .collect(); + let format_parts: Vec<_> = + args.iter().map(|arg| self.value_placeholder(arg)).collect(); let format_str = format_parts.join(" "); Ok(Some(quote! { #func_tokens!(#format_str, #(#all_args),*) })) } } else { // No format string, treat all as separate values - let format_parts: Vec<_> = args - .iter() - .map(|arg| match &arg.kind { - ExprKind::Literal(Literal::String(_)) => "{}", - _ => "{:?}", - }) - .collect(); + let format_parts: Vec<_> = + args.iter().map(|arg| self.value_placeholder(arg)).collect(); let format_str = format_parts.join(" "); Ok(Some(quote! { #func_tokens!(#format_str, #(#all_args),*) })) } @@ -251,6 +243,353 @@ impl Transpiler { } } +/// PRINTSTR-1: the type tag `variable_types` holds for a string binding. +const STRING_VAR_TYPE: &str = "String"; + +/// STRRECV-1: the type tag `variable_types` holds for a binding to a list +/// literal, whose `repeat` is a `Vec`. +const LIST_VAR_TYPE: &str = "Vec"; + +/// PRINTSTR-1: methods whose result prints as text on every receiver they +/// exist on (`to_string` on anything, the case/trim methods on `str`/`char`). +const STRING_RESULT_METHODS: &[&str] = &[ + "to_string", + "to_uppercase", + "to_lowercase", + "trim", + "trim_start", + "trim_end", +]; + +impl Transpiler { + /// PRINTSTR-1: `{}` for an expression known to be a string, `{:?}` for + /// anything else (a number prints the same either way). (complexity: 2) + pub(crate) fn value_placeholder(&self, expr: &Expr) -> &'static str { + if self.is_display_string(expr) { + "{}" + } else { + "{:?}" + } + } + + /// PRINTSTR-1: `expr` is a string in the transpiled Rust: a literal, + /// an interpolation, `format!`/`format(..)`, a string-returning method, + /// or a variable/parameter recorded as a string. (complexity: 6) + pub(crate) fn is_display_string(&self, expr: &Expr) -> bool { + match &expr.kind { + ExprKind::Literal(Literal::String(_)) | ExprKind::StringInterpolation { .. } => true, + ExprKind::Macro { name, .. } | ExprKind::MacroInvocation { name, .. } => { + name.trim_end_matches('!') == "format" + } + ExprKind::Call { func, args } => self.is_format_fn_call(func, args), + ExprKind::MethodCall { + receiver, + method, + args, + } => self.is_string_method_result(receiver, method, args), + ExprKind::Identifier(name) => self + .variable_types + .borrow() + .get(name) + .is_some_and(|t| t == STRING_VAR_TYPE), + _ => false, + } + } + + /// PRINTSTR-1: record whether the binding `name` holds a string, from its + /// annotation when present, else from its value. A rebinding to a + /// non-string clears the record. DICTTYPE-1: a typed-value dict literal + /// is recorded too. (complexity: 2) + pub(crate) fn track_string_binding(&self, name: &str, value: &Expr, ty: Option<&Type>) { + let is_string = ty.map_or_else(|| self.is_display_string(value), is_string_annotation); + self.set_string_var(name, is_string); + self.set_list_var( + name, + ty.map_or_else(|| self.is_known_list(value), is_list_annotation), + ); + self.track_typed_dict_binding(name, value); + } + + /// PRINTSTR-1: parameters of the function being transpiled; string + /// records of earlier functions are dropped first. (complexity: 2) + pub(crate) fn track_string_params(&self, params: &[Param]) { + self.variable_types + .borrow_mut() + .retain(|_, t| t != STRING_VAR_TYPE && t != LIST_VAR_TYPE); + for param in params { + self.set_string_var(¶m.name(), is_string_annotation(¶m.ty)); + self.set_list_var(¶m.name(), is_list_annotation(¶m.ty)); + } + } + + /// PRINTSTRSCOPE-1: `receiver.method(..)` is a string: a text-printing + /// method; STRRECV-1: `replace(from, to)` (`Option::replace` takes one + /// argument), or `repeat` on a receiver not known to be a list. + /// (complexity: 4) + fn is_string_method_result(&self, receiver: &Expr, method: &str, args: &[Expr]) -> bool { + match method { + "replace" => args.len() == 2, + "repeat" => !self.is_known_list(receiver), + _ => STRING_RESULT_METHODS.contains(&method), + } + } + + /// STRRECV-1: record `name` as a list binding (or leave it). (complexity: 2) + fn set_list_var(&self, name: &str, is_list: bool) { + if is_list { + self.variable_types + .borrow_mut() + .insert(name.to_string(), LIST_VAR_TYPE.to_string()); + } + } + + /// STRRECV-1: `expr` is a list literal (`[..]`, `[x; n]`, `vec![..]`, + /// `vec![x; n]`) or a binding recorded as one. (complexity: 4) + fn is_known_list(&self, expr: &Expr) -> bool { + match &expr.kind { + ExprKind::List(_) | ExprKind::ArrayInit { .. } | ExprKind::VecRepeat { .. } => true, + ExprKind::Macro { name, .. } | ExprKind::MacroInvocation { name, .. } => { + name.trim_end_matches('!') == "vec" + } + ExprKind::Identifier(name) => self + .variable_types + .borrow() + .get(name) + .is_some_and(|t| t == LIST_VAR_TYPE), + _ => false, + } + } + + /// PRINTSTRSCOPE-1: run `f` while each `(name, is_string)` binder + /// re-binds `name` (a shadowing binder drops the outer string record); + /// the prior `variable_types` entries of those names are restored after. + /// (complexity: 3) + pub(crate) fn with_string_scope( + &self, + binders: &[(String, bool)], + f: impl FnOnce() -> T, + ) -> T { + let saved: Vec<(String, Option)> = binders + .iter() + .map(|(name, _)| { + ( + name.clone(), + self.variable_types.borrow().get(name).cloned(), + ) + }) + .collect(); + for (name, is_string) in binders { + self.set_string_var(name, *is_string); + } + let result = f(); + let mut types = self.variable_types.borrow_mut(); + for (name, entry) in saved.into_iter().rev() { + match entry { + Some(ty) => types.insert(name, ty), + None => types.remove(&name), + }; + } + result + } + + /// PRINTSTRSCOPE-1: run `f` in the scope of the names `pattern` binds + /// (for-loop, match arm, if-let, while-let). (complexity: 1) + pub(crate) fn with_pattern_scope(&self, pattern: &Pattern, f: impl FnOnce() -> T) -> T { + let binders: Vec<(String, bool)> = extract_pattern_bindings(pattern) + .into_iter() + .map(|name| (name, false)) + .collect(); + self.with_string_scope(&binders, f) + } + + /// STRRECV-1: run `f` in the scope of a for loop's binders; a plain loop + /// variable over string items (see [`is_string_items`]) is a string. + /// (complexity: 2) + pub(crate) fn with_for_scope( + &self, + pattern: &Pattern, + iter: &Expr, + f: impl FnOnce() -> T, + ) -> T { + match pattern { + Pattern::Identifier(name) if is_string_items(iter) => { + self.with_string_scope(&[(name.clone(), true)], f) + } + _ => self.with_pattern_scope(pattern, f), + } + } + + /// PRINTSTRSCOPE-1: run `f` in the scope of closure parameters; a + /// `String`/`&str`-annotated parameter is a string. (complexity: 1) + pub(crate) fn with_param_scope(&self, params: &[Param], f: impl FnOnce() -> T) -> T { + self.with_closure_param_scope(None, params, f) + } + + /// PRINTPARAM-1: [`Self::with_param_scope`] for a closure bound to + /// `closure`: an untyped parameter is a string when every recorded call + /// site passes a string literal at its position. (complexity: 1) + pub(crate) fn with_closure_param_scope( + &self, + closure: Option<&str>, + params: &[Param], + f: impl FnOnce() -> T, + ) -> T { + let binders: Vec<(String, bool)> = params + .iter() + .enumerate() + .map(|(i, p)| (p.name(), self.is_string_closure_param(closure, i, p))) + .collect(); + self.with_string_scope(&binders, f) + } + + /// PRINTPARAM-1: an annotated parameter is a string by its annotation; an + /// untyped one by the call-site type of `closure` at `index`. (complexity: 3) + fn is_string_closure_param(&self, closure: Option<&str>, index: usize, param: &Param) -> bool { + if !matches!(¶m.ty.kind, TypeKind::Named(n) if n == "_") { + return is_string_annotation(¶m.ty); + } + closure + .and_then(|name| self.get_call_site_param_type(name, index)) + .is_some_and(|ty| ty == STRING_VAR_TYPE) + } + + /// PRINTPARAM-1: record the parameters whose emitted type is `String` or + /// `&str`; `param_tokens` holds one `name: Type` per parameter, so an + /// inferred type counts like an annotation. (complexity: 3) + pub(crate) fn track_emitted_string_params(&self, param_tokens: &[TokenStream]) { + for tokens in param_tokens { + let text = tokens.to_string(); + let Some((name, ty)) = text.split_once(':') else { + continue; + }; + if is_string_type_text(ty.trim()) { + let name = name + .trim() + .trim_start_matches("mut ") + .trim_start_matches("r#"); + self.set_string_var(name, true); + } + } + } + + /// PRINTSTR-1: set or clear the string record of `name`; STRRECV-1: a + /// rebinding also clears its list record. (complexity: 3) + fn set_string_var(&self, name: &str, is_string: bool) { + let mut types = self.variable_types.borrow_mut(); + if is_string { + types.insert(name.to_string(), STRING_VAR_TYPE.to_string()); + } else if types + .get(name) + .is_some_and(|t| t == STRING_VAR_TYPE || t == LIST_VAR_TYPE) + { + types.remove(name); + } + } + + /// FORMATFN-1: `func(args)` is the function form of `format!`. + /// (complexity: 2) + fn is_format_fn_call(&self, func: &Expr, args: &[Expr]) -> bool { + matches!(&func.kind, ExprKind::Identifier(n) if self.is_format_fn(n, args)) + } + + /// FORMATFN-1: `name(args)` is `format` with a string-literal format + /// argument, and no user function named `format` exists. (complexity: 3) + fn is_format_fn(&self, name: &str, args: &[Expr]) -> bool { + name == "format" + && matches!( + args.first().map(|a| &a.kind), + Some(ExprKind::Literal(Literal::String(_))) + ) + && !self.function_signatures.contains_key("format") + } + + /// FORMATFN-1: `format(fmt, ..)` transpiles exactly like `format!(fmt, ..)`. + /// (complexity: 2) + pub(crate) fn try_transpile_format_fn( + &self, + name: &str, + args: &[Expr], + ) -> Result> { + if !self.is_format_fn(name, args) { + return Ok(None); + } + let macro_expr = Expr::new( + ExprKind::MacroInvocation { + name: "format".to_string(), + args: args.to_vec(), + }, + args[0].span, + ); + self.transpile_expr(¯o_expr).map(Some) + } +} + +/// STRRECV-1: `ty` is a list type: `[T]`, `[T; N]`, `Vec`, or a +/// reference to one. (complexity: 4) +fn is_list_annotation(ty: &Type) -> bool { + match &ty.kind { + TypeKind::List(_) | TypeKind::Array { .. } => true, + TypeKind::Generic { base, .. } => base == "Vec", + TypeKind::Reference { inner, .. } => is_list_annotation(inner), + _ => false, + } +} + +/// STRRECV-1: iterating `iter` yields strings: `lines()`, +/// `split_whitespace()`, `split()`, or a list of +/// string literals. (complexity: 4) +fn is_string_items(iter: &Expr) -> bool { + match &iter.kind { + ExprKind::MethodCall { method, args, .. } => match method.as_str() { + "lines" | "split_whitespace" => args.is_empty(), + "split" => matches!( + args.first().map(|a| &a.kind), + Some(ExprKind::Literal(Literal::String(_) | Literal::Char(_))) + ), + _ => false, + }, + ExprKind::List(items) => { + !items.is_empty() + && items + .iter() + .all(|i| matches!(i.kind, ExprKind::Literal(Literal::String(_)))) + } + _ => false, + } +} + +/// PRINTPARAM-1: the emitted type text `ty` is `String`, `&str` or +/// `&'a str`. (complexity: 3) +fn is_string_type_text(ty: &str) -> bool { + ty == "String" || ty == "& str" || (ty.starts_with("& '") && ty.ends_with(" str")) +} + +/// PRINTSTR-1: `ty` is `String`, `str` or a reference to one. (complexity: 3) +fn is_string_annotation(ty: &Type) -> bool { + match &ty.kind { + TypeKind::Named(name) => matches!(name.as_str(), "String" | "str" | "&str"), + TypeKind::Reference { inner, .. } => is_string_annotation(inner), + _ => false, + } +} + +/// PRINTLNFMT-1: `s` contains at least one `{…}` placeholder (`{}`, `{:?}`, +/// `{name}`, `{0}`, `{:>5}`, ...), so it is a format string. `{{` escapes +/// do not count. (complexity: 4) +#[must_use] +pub fn has_format_placeholder(s: &str) -> bool { + let mut chars = s.chars().peekable(); + while let Some(c) = chars.next() { + if c != '{' { + continue; + } + if chars.next_if_eq(&'{').is_none() { + return chars.any(|c| c == '}'); + } + } + false +} + // ============================================================================ // Tests // ============================================================================ @@ -287,6 +626,18 @@ mod tests { make_expr(ExprKind::StringInterpolation { parts }) } + #[test] + fn test_printlnfmt_1_has_format_placeholder() { + for s in [ + "{}", "{:?}", "v={:?}", "{:#?}", "{name}", "{0}", "{:>5}|", "{:.2}", "{{ {}", + ] { + assert!(has_format_placeholder(s), "{s}"); + } + for s in ["", "plain", "{{}}", "{{x}}", "{", "}{"] { + assert!(!has_format_placeholder(s), "{s}"); + } + } + // ======================================================================== // transpile_print_with_interpolation tests // ======================================================================== diff --git a/src/backend/transpiler/program_transpiler.rs b/src/backend/transpiler/program_transpiler.rs index 3e5cc3391..a6e5c5371 100644 --- a/src/backend/transpiler/program_transpiler.rs +++ b/src/backend/transpiler/program_transpiler.rs @@ -241,17 +241,10 @@ impl Transpiler { contract_pre_configuration!(name); let module_name = format_ident!("{}", name); let body_tokens = if let ExprKind::Block(exprs) = &body.kind { - let mut module_items = Vec::new(); - for expr in exprs { - match &expr.kind { - ExprKind::Function { .. } => { - module_items.push(self.transpile_function_expr(expr)?); - } - _ => { - module_items.push(self.transpile_expr(expr)?); - } - } - } + let module_items = exprs + .iter() + .map(|expr| self.transpile_module_item(expr)) + .collect::>>()?; quote! { #(#module_items)* } } else { self.transpile_expr(body)? @@ -264,6 +257,24 @@ impl Transpiler { }) } + /// Transpile one item of a module body as a Rust item. + /// + /// A nested module is emitted as an item (`pub mod b { .. }` when marked + /// `pub`, `pub(crate)`/`pub(super)` as recorded), not as an expression whose + /// block body is not an item (NESTEDMOD-1). + /// Complexity: 3 (within Toyota Way limits) + fn transpile_module_item(&self, expr: &Expr) -> Result { + match &expr.kind { + ExprKind::Function { .. } => self.transpile_function_expr(expr), + ExprKind::Module { name, body } => { + let module = self.transpile_module_declaration(name, body)?; + let visibility = module_visibility(expr); + Ok(quote! { #visibility #module }) + } + _ => self.transpile_expr(expr), + } + } + /// Transpile statement-only block /// Complexity: 6 (within Toyota Way limits) fn transpile_statement_only_block( @@ -656,6 +667,21 @@ impl Transpiler { // Tests // ============================================================================ +/// The visibility the parser recorded on a module item: `pub`, `pub(crate)`, +/// `pub(super)`, or nothing for a private module. `pub(in path)` records no +/// argument and is emitted as `pub`. +/// Complexity: 3 (within Toyota Way limits) +fn module_visibility(expr: &Expr) -> TokenStream { + let Some(attr) = expr.attributes.iter().find(|a| a.name == "pub") else { + return TokenStream::new(); + }; + match attr.args.first().map(String::as_str) { + Some("crate") => quote! { pub(crate) }, + Some("super") => quote! { pub(super) }, + _ => quote! { pub }, + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/backend/transpiler/return_type_helpers.rs b/src/backend/transpiler/return_type_helpers.rs index f8877a60f..6e44d2390 100644 --- a/src/backend/transpiler/return_type_helpers.rs +++ b/src/backend/transpiler/return_type_helpers.rs @@ -157,12 +157,7 @@ pub fn returns_vec(body: &Expr) -> bool { ExprKind::Return { value: Some(val) } => returns_vec(val), // Block - check last expression - ExprKind::Block(exprs) => exprs.last().is_some_and(|e| { - // Last expression is array literal - matches!(&e.kind, ExprKind::List(_)) - // OR recursively check - || returns_vec(e) - }), + ExprKind::Block(exprs) => exprs.last().is_some_and(|e| block_tail_is_vec(e, exprs)), // Let expression - check body ExprKind::Let { body: let_body, .. } | ExprKind::LetPattern { body: let_body, .. } => { @@ -173,6 +168,12 @@ pub fn returns_vec(body: &Expr) -> bool { } } +/// Is the tail `e` of a block `exprs` a Vec: a list literal, a nested Vec-returning +/// expression, or (ISSUE-113) an identifier bound to a list earlier in the block. +fn block_tail_is_vec(e: &Expr, exprs: &[Expr]) -> bool { + matches!(&e.kind, ExprKind::List(_)) || returns_vec(e) || expr_creates_or_returns_vec(e, exprs) +} + /// TRANSPILER-013: Check if expression returns an object literal (transpiled to `BTreeMap`) /// Used to infer return type annotation for functions pub fn returns_object_literal(body: &Expr) -> bool { @@ -342,6 +343,90 @@ pub fn expr_creates_or_returns_vec(expr: &Expr, block_exprs: &[Expr]) -> bool { false } +/// LENRET-1: the body's tail is a `len()` / `count()` call. +/// +/// Both are `usize` in the transpiled Rust (an unannotated `let n = v.len()` +/// is a `usize` as well), so the function returns `usize` rather than an +/// integer type its tail cannot produce. (complexity: 2) +#[must_use] +pub fn returns_usize(body: &Expr) -> bool { + get_final_expression(body).is_some_and(is_usize_expr) +} + +/// INTLEN-1: `expr` is a zero-argument `len()` / `count()` call, a `usize` +/// in the transpiled Rust. (complexity: 2) +#[must_use] +pub fn is_usize_expr(expr: &Expr) -> bool { + matches!(&expr.kind, ExprKind::MethodCall { method, args, .. } + if args.is_empty() && matches!(method.as_str(), "len" | "count")) +} + +/// INTLEN-1: the signed/unsigned Rust integer type (other than `usize`) that +/// the Ruchy type name `ty` denotes, if any. (complexity: 2) +#[must_use] +pub fn usize_cast_target(ty: &str) -> Option<&'static str> { + match ty { + "int" | "i64" => Some("i64"), + "i32" => Some("i32"), + "u32" => Some("u32"), + "u64" => Some("u64"), + "isize" => Some("isize"), + _ => None, + } +} + +/// INTLEN-1: `tokens` (the transpiled `expr`) cast to the integer type `ty` +/// when `expr` is a `usize`; unchanged otherwise. (complexity: 2) +#[must_use] +pub fn cast_usize_tokens(expr: &Expr, ty: &str, tokens: TokenStream) -> TokenStream { + match usize_cast_target(ty) { + Some(target) if is_usize_expr(expr) => { + let target = proc_macro2::Ident::new(target, proc_macro2::Span::call_site()); + quote! { (#tokens as #target) } + } + _ => tokens, + } +} + +/// INTLEN-1: `body` with its `usize` tail wrapped in a cast to the declared +/// integer return type `ty`; `None` when no cast is needed. (complexity: 5) +#[must_use] +pub fn cast_usize_tail(body: &Expr, ty: &str) -> Option { + let target = usize_cast_target(ty)?; + let mut out = body.clone(); + let tail = final_expression_mut(&mut out)?; + if !is_usize_expr(tail) { + return None; + } + let inner = std::mem::replace(tail, Expr::new(ExprKind::Literal(Literal::Unit), tail.span)); + *tail = Expr::new( + ExprKind::TypeCast { + expr: Box::new(inner), + target_type: target.to_string(), + }, + body.span, + ); + Some(out) +} + +/// Mutable twin of `get_final_expression`. (complexity: 4) +fn final_expression_mut(expr: &mut Expr) -> Option<&mut Expr> { + let is_wrapper = matches!( + &expr.kind, + ExprKind::Block(_) | ExprKind::Let { .. } | ExprKind::LetPattern { .. } + ); + if !is_wrapper { + return Some(expr); + } + match &mut expr.kind { + ExprKind::Block(exprs) => exprs.last_mut().and_then(final_expression_mut), + ExprKind::Let { body, .. } | ExprKind::LetPattern { body, .. } => { + final_expression_mut(body) + } + _ => None, + } +} + /// Helper: Get the actual final expression, drilling through Let/Block wrappers /// Complexity: 3 (simple recursive pattern matching) pub fn get_final_expression(expr: &Expr) -> Option<&Expr> { @@ -449,6 +534,26 @@ where #[cfg(test)] mod tests { use super::*; + + #[test] + fn test_intlen_1_cast_usize_tail_and_tokens() { + let parse = |src: &str| { + crate::frontend::parser::Parser::new(src) + .parse() + .expect("parse") + }; + let len = parse("xs.len()"); + let cast = cast_usize_tail(&len, "int").expect("int tail is cast"); + assert!( + matches!(&cast.kind, ExprKind::TypeCast { target_type, .. } if target_type == "i64") + ); + assert!(cast_usize_tail(&len, "usize").is_none()); + assert!(cast_usize_tail(&parse("xs.first()"), "int").is_none()); + let tokens = cast_usize_tokens(&len, "i32", quote! { xs.len() }).to_string(); + assert_eq!(tokens.replace(' ', ""), "(xs.len()asi32)"); + let plain = cast_usize_tokens(&parse("x"), "int", quote! { x }).to_string(); + assert_eq!(plain, "x"); + } use crate::frontend::ast::{Expr, ExprKind, Literal, Span}; fn make_expr(kind: ExprKind) -> Expr { @@ -982,4 +1087,33 @@ mod tests { let expr = block(vec![]); assert!(!returns_string(&expr)); } + + fn method(receiver: Expr, name: &str, args: Vec) -> Expr { + make_expr(ExprKind::MethodCall { + receiver: Box::new(receiver), + method: name.to_string(), + args, + }) + } + + // LENRET-1: `len()` / `count()` tails are `usize` + #[test] + fn test_lenret_1_returns_usize_for_len_and_count_tails() { + assert!(returns_usize(&method(ident("xs"), "len", vec![]))); + assert!(returns_usize(&method(ident("xs"), "count", vec![]))); + let tail_after_stmt = block(vec![ident("k"), method(ident("xs"), "len", vec![])]); + assert!(returns_usize(&tail_after_stmt)); + } + + #[test] + fn test_lenret_1_other_tails_are_not_usize() { + assert!(!returns_usize(&ident("xs"))); + assert!(!returns_usize(&method(ident("xs"), "abs", vec![]))); + assert!(!returns_usize(&method( + ident("xs"), + "count", + vec![ident("c")] + ))); + assert!(!returns_usize(&block(vec![]))); + } } diff --git a/src/backend/transpiler/statements.rs b/src/backend/transpiler/statements.rs index 8c95646ea..7bc4dc29d 100644 --- a/src/backend/transpiler/statements.rs +++ b/src/backend/transpiler/statements.rs @@ -241,6 +241,13 @@ impl Transpiler { ) } + /// INTLEN-1: `fun f(..) -> int { xs.len() }` gets its `usize` tail cast + /// to the declared integer type; `None` when the body needs no change. + fn usize_tail_cast_body(body: &Expr, return_type: Option<&Type>) -> Option { + let ty = Self::type_to_string(return_type?); + super::return_type_helpers::cast_usize_tail(body, &ty) + } + /// Transpile function with contract clauses (PMAT-001: Ruchy 5.0 Silver level) /// /// Contract clauses are transpiled to `debug_assert!` calls: @@ -259,6 +266,8 @@ impl Transpiler { contracts: &[crate::frontend::ast::ContractClause], ) -> Result { contract_pre_bce!(); + let cast_body = Self::usize_tail_cast_body(body, return_type); + let body = cast_body.as_ref().unwrap_or(body); if contracts.is_empty() { return self.transpile_function_impl( name, @@ -362,6 +371,10 @@ impl Transpiler { args: &[Expr], ) -> Result { contract_pre_configuration!(); + // NESTPUSHLIT-1: an array literal pushed into a list of vec! literals. + if let Some(args) = self.inner_vec_push_args(object, method, args) { + return self.transpile_method_call_impl(object, method, &args); + } self.transpile_method_call_impl(object, method, args) } diff --git a/src/backend/transpiler/string_body_conversion.rs b/src/backend/transpiler/string_body_conversion.rs index 6ddc92196..feb20f75d 100644 --- a/src/backend/transpiler/string_body_conversion.rs +++ b/src/backend/transpiler/string_body_conversion.rs @@ -109,6 +109,7 @@ impl Transpiler { ) -> Result { let name_ident = format_ident!("{}", name); let is_mutable_var = self.check_mutability(name, is_mutable, let_body); + self.track_string_binding(name, value, type_annotation); let value_tokens = self.convert_value_for_string_context(name, value, type_annotation, is_mutable_var)?; let let_body_tokens = self.transpile_expr(let_body)?; @@ -197,10 +198,13 @@ impl Transpiler { for arm in arms { let pattern_tokens = self.transpile_pattern(&arm.pattern)?; - let body_tokens = self.convert_arm_body_to_string(&arm.body)?; + // PRINTSTRSCOPE-1: arm bindings shadow outer string records. + let body_tokens = self + .with_pattern_scope(&arm.pattern, || self.convert_arm_body_to_string(&arm.body))?; if let Some(guard_expr) = &arm.guard { - let guard_tokens = self.transpile_expr(guard_expr)?; + let guard_tokens = + self.with_pattern_scope(&arm.pattern, || self.transpile_expr(guard_expr))?; arm_tokens.push(quote! { #pattern_tokens if #guard_tokens => #body_tokens }); diff --git a/src/backend/transpiler/types.rs b/src/backend/transpiler/types.rs index 878353e7e..649c0e8eb 100644 --- a/src/backend/transpiler/types.rs +++ b/src/backend/transpiler/types.rs @@ -10,22 +10,22 @@ use crate::frontend::ast::{ use anyhow::{bail, Result}; use proc_macro2::TokenStream; use quote::{format_ident, quote}; +use std::collections::HashSet; use syn::Lifetime; -/// Generate self-receiver tokens based on type kind and mutation flag. +/// Generate self-receiver tokens for a `self` parameter. /// -/// Reduces nesting in match arms that emit `quote! { &mut self }` or `quote! { &self }`. -fn self_receiver_tokens(ty: &TypeKind, mutated_fallback: bool) -> TokenStream { - if let TypeKind::Reference { is_mut, .. } = ty { - if *is_mut { - quote! { &mut self } - } else { - quote! { &self } - } - } else if mutated_fallback { - quote! { &mut self } - } else { - quote! { &self } +/// `&self` / `&mut self` are kept. A bare `self` is an owned receiver (SPECIFICATION +/// §7.11, QUALITY-001): `self`, or `mut self` when declared so. A bare `self` whose +/// body mutates it cannot compile as an immutable owned receiver, so it becomes +/// `&mut self` (TRANSPILER-METHOD-SELF-001). +fn self_receiver_tokens(param: &crate::frontend::ast::Param, mutated: bool) -> TokenStream { + match ¶m.ty.kind { + TypeKind::Reference { is_mut: true, .. } => quote! { &mut self }, + TypeKind::Reference { .. } => quote! { &self }, + _ if param.is_mutable => quote! { mut self }, + _ if mutated => quote! { &mut self }, + _ => quote! { self }, } } @@ -42,6 +42,70 @@ fn self_receiver_tokens_owned(ty: &TypeKind) -> TokenStream { } } +/// The `self` parameter of a method, when its first parameter is `self`. +fn self_param(params: &[crate::frontend::ast::Param]) -> Option<&crate::frontend::ast::Param> { + params.first().filter(|p| p.name() == "self") +} + +/// Declared `&mut self`: mutates `self` whatever its body does. +fn declares_mut_self(params: &[crate::frontend::ast::Param]) -> bool { + self_param(params) + .is_some_and(|p| matches!(p.ty.kind, TypeKind::Reference { is_mut: true, .. })) +} + +/// Bare `self` (neither a reference nor `mut self`): the receiver is inferred from the body. +fn has_inferred_self(params: &[crate::frontend::ast::Param]) -> bool { + self_param(params) + .is_some_and(|p| !p.is_mutable && !matches!(p.ty.kind, TypeKind::Reference { .. })) +} + +/// The receiver is emitted by value (`self` / `mut self`), see [`self_receiver_tokens`]. +fn has_by_value_receiver(params: &[crate::frontend::ast::Param], mutated: bool) -> bool { + self_param(params).is_some_and(|p| { + !matches!(p.ty.kind, TypeKind::Reference { .. }) && (p.is_mutable || !mutated) + }) +} + +/// Methods of one impl that mutate `self` (fixpoint over sibling calls). +fn impl_mutating_methods(methods: &[ImplMethod]) -> HashSet { + let seed = methods + .iter() + .filter(|m| declares_mut_self(&m.params)) + .map(|m| m.name.clone()) + .collect(); + let candidates: Vec<(&str, &crate::frontend::ast::Expr)> = methods + .iter() + .filter(|m| has_inferred_self(&m.params)) + .map(|m| (m.name.as_str(), &*m.body)) + .collect(); + mutation_detection::mutating_self_methods(&candidates, seed) +} + +/// Whether an impl method's `self` is mutated: from the impl fixpoint for a bare +/// `self`, from the body for an explicit receiver. +fn impl_self_is_mutated(method: &ImplMethod, mutating: &HashSet) -> bool { + if has_inferred_self(&method.params) { + mutating.contains(&method.name) + } else { + mutation_detection::is_variable_mutated("self", &method.body) + } +} + +/// Methods of one trait that mutate `self`; only default bodies can be inspected. +fn trait_mutating_methods(methods: &[TraitMethod]) -> HashSet { + let seed = methods + .iter() + .filter(|m| declares_mut_self(&m.params)) + .map(|m| m.name.clone()) + .collect(); + let candidates: Vec<(&str, &crate::frontend::ast::Expr)> = methods + .iter() + .filter(|m| has_inferred_self(&m.params)) + .filter_map(|m| m.body.as_deref().map(|body| (m.name.as_str(), body))) + .collect(); + mutation_detection::mutating_self_methods(&candidates, seed) +} + impl Transpiler { /// Transpiles type annotations /// @@ -424,7 +488,7 @@ impl Transpiler { let field_tokens: Vec = fields .iter() .map(|field| { - let field_name = format_ident!("{}", field.name); + let field_name = Self::safe_ident(&field.name); // RESFIELD-1 // BOOK-COMPAT-001: Add lifetime to reference types if needed let field_type = if needs_lifetime { @@ -503,7 +567,7 @@ impl Transpiler { let default_field_tokens: Result> = fields .iter() .map(|field| -> Result { - let field_name = format_ident!("{}", field.name); + let field_name = Self::safe_ident(&field.name); // RESFIELD-1 if let Some(ref default_expr) = field.default_value { let default_value = self.transpile_expr(default_expr)?; // BOOK-COMPAT-004: Add .to_string() for String fields with string literal defaults @@ -759,10 +823,11 @@ impl Transpiler { } else { quote! {} }; + // RAWIDENT-1: a named constructor may collide with a reserved word. let method_name = ctor .name .as_ref() - .map_or_else(|| format_ident!("new"), |n| format_ident!("{}", n)); + .map_or_else(|| format_ident!("new"), |n| Transpiler::safe_ident(n)); let return_type = if let Some(ref ret_ty) = ctor.return_type { let ret_tokens = self.transpile_type(ret_ty)?; quote! { -> #ret_tokens } @@ -788,7 +853,7 @@ impl Transpiler { methods .iter() .map(|method| { - let method_name = format_ident!("{}", method.name); + let method_name = Transpiler::safe_ident(&method.name); // RAWIDENT-1 let params = self.transpile_params(&method.params)?; let return_type = if let Some(ref ret_ty) = method.return_type { let ret_tokens = self.transpile_type(ret_ty)?; @@ -991,7 +1056,7 @@ impl Transpiler { let field_defs: Vec = fields .iter() .map(|field| { - let field_name = format_ident!("{}", field.name); + let field_name = Transpiler::safe_ident(&field.name); // ENUMFIELDKW-1 let ty = &field.ty; let t = self.transpile_type(ty); let field_type = t.unwrap_or_else(|_| quote! { _ }); @@ -1064,66 +1129,11 @@ impl Transpiler { }) .collect(); - let method_tokens: Result> = methods + let mutating = trait_mutating_methods(methods); + let method_tokens = methods .iter() - .map(|method| { - let method_name = format_ident!("{}", method.name); - // TRANSPILER-TRAIT-001 FIX: Determine if self is mutated for &mut self inference - // For traits with default implementations, check the body for mutations - let self_is_mutated = method.body.as_ref().is_some_and(|body| { - crate::backend::transpiler::mutation_detection::is_variable_mutated( - "self", body, - ) - }); - // Process parameters - let param_tokens: Vec = method - .params - .iter() - .enumerate() - .map(|(i, param)| { - if i == 0 && (param.name() == "self" || param.name() == "&self") { - // TRANSPILER-TRAIT-001 FIX: Handle self parameter consistently - if param.name().starts_with('&') { - // Legacy: name-based detection (e.g., "&self" as name) - quote! { &self } - } else { - self_receiver_tokens(¶m.ty.kind, self_is_mutated) - } - } else { - let param_name = format_ident!("{}", param.name()); - let type_tokens = self - .transpile_type(¶m.ty) - .unwrap_or_else(|_| quote! { _ }); - quote! { #param_name: #type_tokens } - } - }) - .collect(); - // Process return type - let return_type_tokens = if let Some(ref ty) = method.return_type { - let ty_tokens = self.transpile_type(ty)?; - quote! { -> #ty_tokens } - } else { - quote! {} - }; - // TRANSPILER-TRAIT-001 FIX: Trait methods cannot have visibility modifiers - // In Rust, trait method declarations are implicitly public. - // Adding `pub` causes prettyplease to fail with "not implemented: TraitItem::Verbatim" - // Process method body (if default implementation) - if let Some(ref body) = method.body { - let body_tokens = self.transpile_expr(body)?; - Ok(quote! { - fn #method_name(#(#param_tokens),*) #return_type_tokens { - #body_tokens - } - }) - } else { - Ok(quote! { - fn #method_name(#(#param_tokens),*) #return_type_tokens; - }) - } - }) - .collect(); - let method_tokens = method_tokens?; + .map(|method| self.transpile_trait_method(method, &mutating)) + .collect::>>()?; let type_param_tokens: Vec<_> = type_params .iter() .map(|p| Self::parse_type_param_to_tokens(p)) @@ -1149,6 +1159,67 @@ impl Transpiler { }) } } + /// One trait method: a declaration, or a default method when it has a body. + /// A default method whose receiver is by value gets `where Self: Sized` (the + /// body moves `self`, rustc E0277 otherwise); a bodiless one needs no bound. + fn transpile_trait_method( + &self, + method: &TraitMethod, + mutating: &HashSet, + ) -> Result { + let method_name = Transpiler::safe_ident(&method.name); // RAWIDENT-1 + // TRANSPILER-TRAIT-001: bare `self` in a default method that mutates it is `&mut self` + let self_is_mutated = mutating.contains(&method.name); + let param_tokens: Vec = method + .params + .iter() + .enumerate() + .map(|(i, param)| self.trait_param_tokens(i, param, self_is_mutated)) + .collect(); + let return_type_tokens = match method.return_type { + Some(ref ty) => { + let ty_tokens = self.transpile_type(ty)?; + quote! { -> #ty_tokens } + } + None => quote! {}, + }; + // TRANSPILER-TRAIT-001: trait methods carry no visibility (implicitly public) + let Some(ref body) = method.body else { + return Ok(quote! { fn #method_name(#(#param_tokens),*) #return_type_tokens; }); + }; + let body_tokens = self.transpile_expr(body)?; + let sized_bound = if has_by_value_receiver(&method.params, self_is_mutated) { + quote! { where Self: Sized } + } else { + quote! {} + }; + Ok(quote! { + fn #method_name(#(#param_tokens),*) #return_type_tokens #sized_bound { + #body_tokens + } + }) + } + + /// Parameter `i` of a trait method; the first one may be the `self` receiver. + fn trait_param_tokens( + &self, + i: usize, + param: &crate::frontend::ast::Param, + self_is_mutated: bool, + ) -> TokenStream { + match param.name().as_str() { + // Legacy: name-based detection ("&self" as the parameter name) + "&self" if i == 0 => quote! { &self }, + "self" if i == 0 => self_receiver_tokens(param, self_is_mutated), + _ => { + let param_name = format_ident!("{}", param.name()); + let type_tokens = self + .transpile_type(¶m.ty) + .unwrap_or_else(|_| quote! { _ }); + quote! { #param_name: #type_tokens } + } + } + } /// Transpiles impl blocks pub fn transpile_impl( &self, @@ -1162,17 +1233,16 @@ impl Transpiler { // e.g., "Container" -> "Container" let base_type = for_type.split('<').next().unwrap_or(for_type).trim(); let type_ident = format_ident!("{}", base_type); + let mutating = impl_mutating_methods(methods); let method_tokens: Result> = methods .iter() .map(|method| { - let method_name = format_ident!("{}", method.name); - // TRANSPILER-METHOD-SELF-001 FIX: Check if self is mutated in method body - // to infer &mut self vs &self when not explicitly annotated - let self_is_mutated = - crate::backend::transpiler::mutation_detection::is_variable_mutated( - "self", - &method.body, - ); + let method_name = Transpiler::safe_ident(&method.name); // RAWIDENT-1 + // TRANSPILER-METHOD-SELF-001 FIX: Check if self is mutated in method body + // to infer &mut self vs &self when not explicitly annotated + // RHLGA-1: bare `self` also mutates through nested/indexed fields, mutating + // std methods on a field and calls to mutating sibling methods (fixpoint) + let self_is_mutated = impl_self_is_mutated(method, &mutating); // Process parameters let param_tokens: Vec = method .params @@ -1183,7 +1253,7 @@ impl Transpiler { // Method receivers in Rust have special syntax that differs from normal parameters if name == "self" { // QUALITY-001 + TRANSPILER-METHOD-SELF-001: self receiver - self_receiver_tokens(¶m.ty.kind, self_is_mutated) + self_receiver_tokens(param, self_is_mutated) } else { let param_name = format_ident!("{}", param.name()); let type_tokens = self @@ -1316,8 +1386,8 @@ impl Transpiler { let trait_method_tokens: Result> = methods .iter() .map(|method| { - let method_name = format_ident!("{}", method.name); - // Process parameters + let method_name = Transpiler::safe_ident(&method.name); // RAWIDENT-1 + // Process parameters let param_tokens: Vec = method .params .iter() @@ -1352,8 +1422,8 @@ impl Transpiler { let impl_method_tokens: Result> = methods .iter() .map(|method| { - let method_name = format_ident!("{}", method.name); - // Process parameters (same as trait) + let method_name = Transpiler::safe_ident(&method.name); // RAWIDENT-1 + // Process parameters (same as trait) let param_tokens: Vec = method .params .iter() diff --git a/src/backend/transpiler/utility_builtins.rs b/src/backend/transpiler/utility_builtins.rs index 760a6e0b0..6ccb80d8b 100644 --- a/src/backend/transpiler/utility_builtins.rs +++ b/src/backend/transpiler/utility_builtins.rs @@ -165,7 +165,8 @@ impl Transpiler { base_name: &str, args: &[Expr], ) -> Result> { - if base_name != "range" { + // RHLGA-1: a user-defined `range` function shadows the builtin + if base_name != "range" || self.function_signatures.contains_key("range") { return Ok(None); } diff --git a/src/bin/handlers/command_router.rs b/src/bin/handlers/command_router.rs index 280361d2d..de261c8ad 100644 --- a/src/bin/handlers/command_router.rs +++ b/src/bin/handlers/command_router.rs @@ -67,7 +67,8 @@ pub fn handle_complex_command(command: crate::Commands) -> Result<()> { | crate::Commands::QualityGate { .. } | crate::Commands::Fmt { .. } | crate::Commands::Lint { .. } - | crate::Commands::Coverage { .. }) => dispatch_analysis(cmd), + | crate::Commands::Coverage { .. } + | crate::Commands::Prove { .. }) => dispatch_analysis(cmd), // Interactive and tooling commands cmd => dispatch_tooling(cmd), } @@ -276,7 +277,9 @@ fn dispatch_analysis(command: crate::Commands) -> Result<()> { &format, verbose, ), - _ => unreachable!("dispatch_analysis called with non-analysis command"), + _ => Err(routing_error( + "dispatch_analysis received a non-analysis command", + )), } } @@ -634,13 +637,50 @@ fn dispatch_tooling(command: crate::Commands) -> Result<()> { } => crate::handlers::handlers_modules::sovereign::handle_suggest_contracts( &path, &format, verbose, pub_only, &exclude, ), - _ => { - eprintln!("Command not yet implemented"); - Ok(()) - } + // Exhaustive on purpose (G2B-S2): no wildcard arm, so a new `Commands` + // variant does not compile until it is routed somewhere. + crate::Commands::Ast { .. } + | crate::Commands::Provability { .. } + | crate::Commands::Runtime { .. } + | crate::Commands::Score { .. } + | crate::Commands::QualityGate { .. } + | crate::Commands::Fmt { .. } + | crate::Commands::Lint { .. } + | crate::Commands::Coverage { .. } + | crate::Commands::Prove { .. } => Err(routing_error( + "an analysis command reached dispatch_tooling", + )), + crate::Commands::Repl { .. } + | crate::Commands::New { .. } + | crate::Commands::Build { .. } + | crate::Commands::Publish { .. } + | crate::Commands::Parse { .. } + | crate::Commands::Transpile { .. } + | crate::Commands::Run { .. } + | crate::Commands::Compile { .. } + | crate::Commands::Check { .. } + | crate::Commands::Fix { .. } + | crate::Commands::Convert { .. } + | crate::Commands::Vocab { .. } + | crate::Commands::Explain { .. } + | crate::Commands::Test { .. } + | crate::Commands::PropertyTests { .. } + | crate::Commands::Mutations { .. } + | crate::Commands::Fuzz { .. } + | crate::Commands::Oracle { .. } + | crate::Commands::Hunt { .. } + | crate::Commands::Report { .. } => Err(routing_error( + "this command is dispatched by main, not the command router", + )), } } +/// An internal CLI routing defect: a command reached a dispatcher that does not +/// own it. Returned as an error (never a silent `Ok`) so the process exits non-zero. +fn routing_error(detail: &str) -> anyhow::Error { + anyhow::anyhow!("internal CLI routing error: {detail}") +} + #[cfg(test)] mod tests { use super::*; @@ -783,4 +823,53 @@ mod tests { let err = validate_lint_args(None, false).unwrap_err(); assert!(err.contains("file") || err.contains("--all")); } + + // ======================================================================== + // G2B-S2 (RHLGA-1): exhaustive routing, no silent catch-all + // ======================================================================== + + fn missing_path() -> std::path::PathBuf { + let p = std::env::temp_dir().join(format!("ruchy-router-missing-{}", std::process::id())); + assert!(!p.exists(), "{} unexpectedly exists", p.display()); + p + } + + fn prove_check(file: std::path::PathBuf) -> crate::Commands { + crate::Commands::Prove { + file: Some(file), + backend: "z3".to_string(), + ml_suggestions: false, + timeout: 5000, + script: None, + export: None, + check: true, + counterexample: false, + verbose: false, + format: "text".to_string(), + } + } + + #[test] + fn test_command_router_prove_reaches_the_prover() { + // A missing file is an error only if the prover actually ran. + let result = handle_complex_command(prove_check(missing_path())); + assert!(result.is_err(), "prove must be routed, not swallowed"); + } + + #[test] + fn test_command_router_prove_check_on_valid_file_succeeds() { + let file = tempfile::NamedTempFile::new().expect("temp file"); + std::fs::write(file.path(), "let x = 42").expect("write"); + let result = handle_complex_command(prove_check(file.path().to_path_buf())); + assert!(result.is_ok(), "prove --check failed: {result:?}"); + } + + #[test] + fn test_command_router_main_routed_variant_is_an_error_not_a_silent_ok() { + let result = handle_complex_command(crate::Commands::Parse { + file: missing_path(), + }); + let err = result.expect_err("a variant main dispatches must not succeed here"); + assert!(err.to_string().contains("routing"), "got: {err}"); + } } diff --git a/src/bin/handlers/fuzz_handler.rs b/src/bin/handlers/fuzz_handler.rs index 1f05027bf..32a74c959 100644 --- a/src/bin/handlers/fuzz_handler.rs +++ b/src/bin/handlers/fuzz_handler.rs @@ -258,7 +258,7 @@ fn handle_fuzz_single_file( let (successes, crashes, timeouts, crash_details) = run_fuzz_iterations(&binary_path, iterations, verbose)?; - let _ = fs::remove_file(&binary_path); + drop(binary_path); let total = successes + crashes + timeouts; let success_rate = (successes as f64 / total as f64) * 100.0; diff --git a/src/bin/handlers/handlers_modules/sovereign.rs b/src/bin/handlers/handlers_modules/sovereign.rs index c0d934319..8f59686cf 100644 --- a/src/bin/handlers/handlers_modules/sovereign.rs +++ b/src/bin/handlers/handlers_modules/sovereign.rs @@ -20,7 +20,7 @@ use ruchy::stdlib::simular_bridge::{SimConfig, SimResult}; /// Handle `ruchy infra plan `. pub fn handle_infra_plan(file: &Path) -> anyhow::Result<()> { - verify_file_exists(file, "infra plan")?; + verify_regular_file(file, "infra plan")?; println!("[ruchy infra plan] {}", file.display()); let plan = InfraPlan::empty(); println!(" {}", plan.summary()); @@ -32,7 +32,7 @@ pub fn handle_infra_plan(file: &Path) -> anyhow::Result<()> { /// Handle `ruchy infra apply `. pub fn handle_infra_apply(file: &Path, auto_approve: bool) -> anyhow::Result<()> { - verify_file_exists(file, "infra apply")?; + verify_regular_file(file, "infra apply")?; println!("[ruchy infra apply] {}", file.display()); let plan = InfraPlan::empty(); if !auto_approve && plan.has_changes() { @@ -48,7 +48,7 @@ pub fn handle_infra_apply(file: &Path, auto_approve: bool) -> anyhow::Result<()> /// Handle `ruchy infra drift `. pub fn handle_infra_drift(file: &Path) -> anyhow::Result<()> { - verify_file_exists(file, "infra drift")?; + verify_regular_file(file, "infra drift")?; println!("[ruchy infra drift] {}", file.display()); let state = InfraState::empty(); if state.resource_count() == 0 { @@ -69,7 +69,7 @@ pub fn handle_infra_status() -> anyhow::Result<()> { /// Handle `ruchy infra destroy `. pub fn handle_infra_destroy(file: &Path, auto_approve: bool) -> anyhow::Result<()> { - verify_file_exists(file, "infra destroy")?; + verify_regular_file(file, "infra destroy")?; println!("[ruchy infra destroy] {}", file.display()); let state = InfraState::empty(); if state.resource_count() == 0 { @@ -691,6 +691,16 @@ fn verify_file_exists(file: &Path, cmd: &str) -> anyhow::Result<()> { Ok(()) } +/// Infra specs are files: a directory (e.g. `/nonexistent` on hosts where it +/// exists) must be an error, not a silent no-op (G2B-S2). +fn verify_regular_file(file: &Path, cmd: &str) -> anyhow::Result<()> { + verify_file_exists(file, cmd)?; + if !file.is_file() { + anyhow::bail!("[ruchy {cmd}] {} is not a file", file.display()); + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -746,6 +756,23 @@ mod tests { assert!(handle_infra_destroy(f.path(), true).is_ok()); } + /// G2B-S2: `ruchy infra destroy /nonexistent` exited 0 on hosts where + /// `/nonexistent` is a directory; an infra spec must be a regular file. + #[test] + fn test_infra_destroy_missing_file_is_error() { + assert!(handle_infra_destroy(&missing_path(), true).is_err()); + } + + #[test] + fn test_infra_commands_reject_a_directory() { + let dir = tempfile::tempdir().expect("tempdir"); + let err = handle_infra_destroy(dir.path(), true).expect_err("directory is not a spec"); + assert!(err.to_string().contains("not a file"), "got: {err}"); + assert!(handle_infra_plan(dir.path()).is_err()); + assert!(handle_infra_apply(dir.path(), true).is_err()); + assert!(handle_infra_drift(dir.path()).is_err()); + } + #[test] fn test_sim_run_with_seed() { let f = temp_file(); diff --git a/src/bin/handlers/notebook_handler.rs b/src/bin/handlers/notebook_handler.rs index fa4c17301..1c4a67fd6 100644 --- a/src/bin/handlers/notebook_handler.rs +++ b/src/bin/handlers/notebook_handler.rs @@ -17,7 +17,6 @@ fn validate_notebook_file(path: &Path) -> Result<()> { compile_rust_code, parse_source, prepare_compilation, read_file_with_context, transpile_for_execution, }; - use std::fs; println!("📓 Notebook validation mode for: {}", path.display()); @@ -28,11 +27,9 @@ fn validate_notebook_file(path: &Path) -> Result<()> { let (temp_source, binary_path) = prepare_compilation(&rust_code, false)?; compile_rust_code(temp_source.path(), &binary_path)?; - // Execute the file to validate it runs + // Execute the file to validate it runs; the binary drops on every path. let result = std::process::Command::new(&binary_path).output()?; - - // Cleanup - let _ = fs::remove_file(&binary_path); + drop(binary_path); if result.status.success() { println!("✅ Notebook validation: PASSED"); diff --git a/src/bin/handlers/property_tests_handler.rs b/src/bin/handlers/property_tests_handler.rs index c02ecc6ac..288821622 100644 --- a/src/bin/handlers/property_tests_handler.rs +++ b/src/bin/handlers/property_tests_handler.rs @@ -4,7 +4,7 @@ use anyhow::Result; use std::fs; -use std::path::{Path, PathBuf}; +use std::path::Path; /// Run property test suite via cargo test /// Complexity: 3 (Toyota Way: <10) @@ -106,7 +106,12 @@ pub fn handle_property_tests_command( } /// Compile Ruchy file for property testing -pub(crate) fn compile_for_property_testing(path: &Path, verbose: bool) -> Result { +/// +/// TMPLEAK-1: the binary is removed when the returned path drops. +pub(crate) fn compile_for_property_testing( + path: &Path, + verbose: bool, +) -> Result { use super::{compile_rust_code, prepare_compilation, transpile_for_execution}; use crate::handlers::transpile_handler::parse_source; @@ -322,8 +327,7 @@ fn handle_property_tests_single_file( if !deterministic { test_results.push("Determinism test: FAILED - outputs differ".to_string()); } - - let _ = fs::remove_file(&binary_path); + drop(binary_path); generate_property_test_report( path, diff --git a/src/bin/handlers/run_handler.rs b/src/bin/handlers/run_handler.rs index 2cce514b7..429c1781f 100644 --- a/src/bin/handlers/run_handler.rs +++ b/src/bin/handlers/run_handler.rs @@ -6,7 +6,7 @@ use anyhow::{Context, Result}; use ruchy::frontend::ast::Expr; use ruchy::{Parser as RuchyParser, Transpiler}; use std::fs; -use std::path::{Path, PathBuf}; +use std::path::Path; /// VM execution mode (OPT-004) #[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)] @@ -114,10 +114,13 @@ pub fn transpile_for_execution(ast: &Expr, file: &Path) -> Result { } /// Prepare compilation artifacts (complexity: 4) +/// +/// TMPLEAK-1: the binary path is a [`tempfile::TempPath`], so the compiled +/// binary is removed when it drops, on every return path of the caller. pub fn prepare_compilation( rust_code: &str, verbose: bool, -) -> Result<(tempfile::NamedTempFile, PathBuf)> { +) -> Result<(tempfile::NamedTempFile, tempfile::TempPath)> { let temp_source = tempfile::NamedTempFile::new().with_context(|| "Failed to create temporary file")?; fs::write(temp_source.path(), rust_code).with_context(|| "Failed to write temporary file")?; @@ -135,6 +138,8 @@ pub fn prepare_compilation( std::process::id(), unique_id )); + let binary_path = tempfile::TempPath::try_from_path(binary_path) + .with_context(|| "Failed to register temporary binary path")?; Ok((temp_source, binary_path)) } diff --git a/src/bin/handlers/serve_handler.rs b/src/bin/handlers/serve_handler.rs index 087b5be9e..6bdd23b3e 100644 --- a/src/bin/handlers/serve_handler.rs +++ b/src/bin/handlers/serve_handler.rs @@ -5,6 +5,26 @@ use anyhow::Result; use std::path::Path; +/// Reject a serve root that is missing or is not a directory. +/// +/// Runs in every build so `ruchy serve ./missing` reports the path problem +/// whether or not the HTTP server itself was compiled in. +fn validate_serve_directory(directory: &Path) -> Result<()> { + if !directory.exists() { + return Err(anyhow::anyhow!( + "Directory not found: {}", + directory.display() + )); + } + if !directory.is_dir() { + return Err(anyhow::anyhow!( + "Path is not a directory: {}", + directory.display() + )); + } + Ok(()) +} + /// Handle serve command - serve static files over HTTP /// /// # Arguments @@ -27,33 +47,104 @@ pub fn handle_serve_command( pid_file: Option<&Path>, watch_wasm: bool, ) -> Result<()> { - use axum::{http::HeaderValue, Router}; - use tower::ServiceBuilder; - use tower_http::{services::ServeDir, set_header::SetResponseHeaderLayer}; + let request = ServeRequest { + directory, + port, + host, + verbose, + watch, + debounce, + pid_file, + watch_wasm, + }; + serve_until(&request, shutdown_on_signal) +} - // Verify directory exists - if !directory.exists() { - return Err(anyhow::anyhow!( - "Directory not found: {}", - directory.display() - )); - } - if !directory.is_dir() { - return Err(anyhow::anyhow!( - "Path is not a directory: {}", - directory.display() - )); - } +/// The arguments of one `ruchy serve` invocation. +#[cfg(feature = "notebook")] +struct ServeRequest<'a> { + directory: &'a Path, + port: u16, + host: &'a str, + verbose: bool, + watch: bool, + debounce: u64, + pid_file: Option<&'a Path>, + watch_wasm: bool, +} + +/// Serve `request` until the channel returned by `install_shutdown` receives. +/// +/// `install_shutdown` runs once the server is set up. The CLI passes +/// [`shutdown_on_signal`] (Ctrl+C / SIGTERM); tests pass a channel they +/// control, so they drive the whole serve path without serving forever. +/// Complexity: 4 (Toyota Way: <10) +#[cfg(feature = "notebook")] +fn serve_until( + request: &ServeRequest<'_>, + install_shutdown: impl FnOnce() -> std::sync::mpsc::Receiver<()>, +) -> Result<()> { + validate_serve_directory(request.directory)?; // Initialize PID file if requested - let _pid_guard = if let Some(pid_path) = pid_file { - Some(ruchy::server::PidFile::create(pid_path)?) - } else { - None - }; + let _pid_guard = request + .pid_file + .map(ruchy::server::PidFile::create) + .transpose()?; // World-class UX: Colored startup banner (vite-style) - print_startup_banner(host, port, directory, watch, watch_wasm); + print_startup_banner( + request.host, + request.port, + request.directory, + request.watch, + request.watch_wasm, + ); + let app = build_static_app(request.directory); + + // PERFORMANCE: Create optimized tokio runtime (multi-threaded, CPU-bound) + let num_cpus = std::thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(1); + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(num_cpus) + .enable_all() + .build()?; + + let shutdown_rx = install_shutdown(); + if request.watch { + run_watch_mode( + &runtime, + &app, + request.directory, + request.host, + request.port, + request.verbose, + request.debounce, + request.watch_wasm, + num_cpus, + &shutdown_rx, + ) + } else { + run_normal_mode( + &runtime, + app, + request.host, + request.port, + request.verbose, + num_cpus, + &shutdown_rx, + ) + } +} + +/// Static file service for `directory` with the WASM isolation headers. +/// Complexity: 1 (Toyota Way: <10) +#[cfg(feature = "notebook")] +fn build_static_app(directory: &Path) -> axum::Router { + use axum::{http::HeaderValue, Router}; + use tower::ServiceBuilder; + use tower_http::{services::ServeDir, set_header::SetResponseHeaderLayer}; // Build the Axum app with static file serving + WASM headers let serve_dir = ServeDir::new(directory) @@ -63,7 +154,7 @@ pub fn handle_serve_command( // Add WASM-specific headers for SharedArrayBuffer support (HTTP-003) // Required for: WebAssembly threading, SharedArrayBuffer, Atomics // Reference: https://web.dev/coop-coep/ - let app = Router::new().fallback_service(serve_dir).layer( + Router::new().fallback_service(serve_dir).layer( ServiceBuilder::new() // Cross-Origin-Opener-Policy: Isolate browsing context .layer(SetResponseHeaderLayer::if_not_present( @@ -75,19 +166,16 @@ pub fn handle_serve_command( axum::http::header::HeaderName::from_static("cross-origin-embedder-policy"), HeaderValue::from_static("require-corp"), )), - ); - - // PERFORMANCE: Create optimized tokio runtime (multi-threaded, CPU-bound) - let num_cpus = std::thread::available_parallelism() - .map(|n| n.get()) - .unwrap_or(1); - let runtime = tokio::runtime::Builder::new_multi_thread() - .worker_threads(num_cpus) - .enable_all() - .build()?; + ) +} - // Setup signal handling for graceful shutdown (Ctrl+C) - #[cfg(unix)] +/// A channel that receives once the process gets SIGINT or SIGTERM (Ctrl+C). +/// +/// On non-Unix targets no handler is installed: the channel never receives +/// and Ctrl+C ends the process through the default handler. +/// Complexity: 2 (Toyota Way: <10) +#[cfg(feature = "notebook")] +fn shutdown_on_signal() -> std::sync::mpsc::Receiver<()> { let (shutdown_tx, shutdown_rx) = std::sync::mpsc::channel::<()>(); #[cfg(unix)] @@ -95,43 +183,18 @@ pub fn handle_serve_command( use signal_hook::consts::{SIGINT, SIGTERM}; use signal_hook::iterator::Signals; - let shutdown_tx_clone = shutdown_tx; std::thread::spawn(move || { let mut signals = Signals::new([SIGINT, SIGTERM]).expect("Failed to register signal handlers"); if let Some(_sig) = signals.forever().next() { - let _ = shutdown_tx_clone.send(()); + let _ = shutdown_tx.send(()); } }); } + #[cfg(not(unix))] + drop(shutdown_tx); - #[allow(unreachable_code)] // Watch mode and Unix signal handling both return early - if watch { - run_watch_mode( - &runtime, - &app, - directory, - host, - port, - verbose, - debounce, - watch_wasm, - num_cpus, - #[cfg(unix)] - &shutdown_rx, - ) - } else { - run_normal_mode( - &runtime, - app, - host, - port, - verbose, - num_cpus, - #[cfg(unix)] - &shutdown_rx, - ) - } + shutdown_rx } /// Print colored startup banner (vite-style) @@ -195,8 +258,11 @@ fn print_startup_banner(host: &str, port: u16, directory: &Path, watch: bool, wa } } -/// Run server in watch mode with file change detection -/// Complexity: 8 (Toyota Way: <10) +/// Run server in watch mode with file change detection. +/// +/// A server that ends on its own (e.g. the address cannot be bound) returns +/// its error, as in normal mode. +/// Complexity: 3 (Toyota Way: <10) #[cfg(feature = "notebook")] #[allow(clippy::too_many_arguments)] fn run_watch_mode( @@ -209,46 +275,107 @@ fn run_watch_mode( debounce: u64, watch_wasm: bool, num_cpus: usize, - #[cfg(unix)] shutdown_rx: &std::sync::mpsc::Receiver<()>, + shutdown_rx: &std::sync::mpsc::Receiver<()>, ) -> Result<()> { + // One watcher for the whole session: re-creating it on every restart spent + // an inotify instance each time, and a failure to get one killed the server. + let Some(mut watcher) = open_watcher(directory, debounce) else { + return run_normal_mode( + runtime, + app.clone(), + host, + port, + verbose, + num_cpus, + shutdown_rx, + ); + }; loop { - let mut watcher = - ruchy::server::watcher::FileWatcher::new(vec![directory.to_path_buf()], debounce)?; - - let addr = format!("{}:{}", host, port); - let app_clone = app.clone(); - let server_handle = runtime.spawn(async move { - let listener = tokio::net::TcpListener::bind(&addr).await?; - - if verbose { - println!("✅ Server started ({} workers)", num_cpus); - } - - axum::serve(listener, app_clone).await - }); + let server_handle = spawn_server(runtime, app.clone(), host, port, verbose, num_cpus); + if let Some(outcome) = serve_until_change( + runtime, + server_handle, + &mut watcher, + watch_wasm, + verbose, + shutdown_rx, + ) { + return outcome; + } + } +} - // Poll for file changes AND shutdown signal - loop { - // Check for shutdown signal - #[cfg(unix)] - if shutdown_rx.try_recv().is_ok() { - print_shutdown_message(); - server_handle.abort(); - return Ok(()); - } +/// Serve until shutdown is requested or the server task ends (`Some`, the +/// outcome) or a watched file changes (`None`, the server has stopped and its +/// address is free to bind again). +/// Complexity: 3 (Toyota Way: <10) +#[cfg(feature = "notebook")] +fn serve_until_change( + runtime: &tokio::runtime::Runtime, + mut server_handle: tokio::task::JoinHandle>, + watcher: &mut ruchy::server::watcher::FileWatcher, + watch_wasm: bool, + verbose: bool, + shutdown_rx: &std::sync::mpsc::Receiver<()>, +) -> Option> { + while !stopped_on_change(runtime, &mut server_handle, watcher, watch_wasm, verbose) { + if let Some(outcome) = poll_server(runtime, &mut server_handle, shutdown_rx) { + return Some(outcome); + } + std::thread::sleep(std::time::Duration::from_millis(100)); + } + None +} - if let Some(changed_files) = watcher.check_changes() { - handle_file_changes(&changed_files, watch_wasm, verbose); - server_handle.abort(); - print_restart_message(); - break; - } +/// When a watched file changed: handle it, stop the server and wait for the +/// aborted task so its listener is closed before the restart rebinds (`true`). +/// Complexity: 2 (Toyota Way: <10) +#[cfg(feature = "notebook")] +fn stopped_on_change( + runtime: &tokio::runtime::Runtime, + server_handle: &mut tokio::task::JoinHandle>, + watcher: &mut ruchy::server::watcher::FileWatcher, + watch_wasm: bool, + verbose: bool, +) -> bool { + let Some(changed_files) = watcher.check_changes() else { + return false; + }; + handle_file_changes(&changed_files, watch_wasm, verbose); + server_handle.abort(); + let _ = runtime.block_on(server_handle); + print_restart_message(); + true +} - std::thread::sleep(std::time::Duration::from_millis(100)); +/// Open the file watcher for `directory`, or `None` (with a warning on stderr) +/// when the host cannot watch it. +/// +/// `--watch` used to exit the server when the watcher could not be created. On a +/// busy host the per-user inotify instance limit (`fs.inotify.max_user_instances`, +/// 128 by default) is exhausted and creation fails with EMFILE ("Too many open +/// files"); the server then keeps serving without reload instead (G2B-S2). +#[cfg(feature = "notebook")] +fn open_watcher(directory: &Path, debounce: u64) -> Option { + match ruchy::server::watcher::FileWatcher::new(vec![directory.to_path_buf()], debounce) { + Ok(watcher) => Some(watcher), + Err(err) => { + eprintln!("{}", watch_unavailable_message(directory, &err)); + None } } } +/// Warning printed when `--watch` cannot watch `directory`. +#[cfg(feature = "notebook")] +fn watch_unavailable_message(directory: &Path, err: &dyn std::fmt::Display) -> String { + format!( + " warning: --watch disabled, cannot watch {}: {err}; serving without reload \ + (if this is \"Too many open files\", raise fs.inotify.max_user_instances)", + directory.display() + ) +} + /// Handle file changes in watch mode /// Complexity: 5 (Toyota Way: <10) #[cfg(feature = "notebook")] @@ -324,8 +451,11 @@ fn print_restart_message() { } } -/// Run server in normal mode (no watch) -/// Complexity: 6 (Toyota Way: <10) +/// Run server in normal mode (no watch) until shutdown is requested. +/// +/// A server that ends on its own (e.g. the address cannot be bound) returns +/// its error instead of leaving the process waiting on a dead server. +/// Complexity: 4 (Toyota Way: <10) #[cfg(feature = "notebook")] fn run_normal_mode( runtime: &tokio::runtime::Runtime, @@ -334,41 +464,25 @@ fn run_normal_mode( port: u16, verbose: bool, num_cpus: usize, - #[cfg(unix)] shutdown_rx: &std::sync::mpsc::Receiver<()>, + shutdown_rx: &std::sync::mpsc::Receiver<()>, ) -> Result<()> { - let addr = format!("{}:{}", host, port); - - #[cfg(unix)] - { - let addr_clone = addr; - let verbose_clone = verbose; - let num_cpus_clone = num_cpus; - let server_future = async move { - let listener = tokio::net::TcpListener::bind(&addr_clone).await?; - - if verbose_clone { - println!("✅ Server started ({} workers)", num_cpus_clone); - } - - axum::serve(listener, app).await - }; - - // Spawn server task - let server_handle = runtime.spawn(server_future); - - // Wait for shutdown signal - loop { - if shutdown_rx.try_recv().is_ok() { - print_shutdown_message(); - server_handle.abort(); - return Ok(()); - } - std::thread::sleep(std::time::Duration::from_millis(100)); - } - } + let server_handle = spawn_server(runtime, app, host, port, verbose, num_cpus); + wait_for_shutdown_or_exit(runtime, server_handle, shutdown_rx) +} - #[cfg(not(unix))] - runtime.block_on(async { +/// Spawn the server task: bind `host:port`, then serve `app` on it. +/// Complexity: 2 (Toyota Way: <10) +#[cfg(feature = "notebook")] +fn spawn_server( + runtime: &tokio::runtime::Runtime, + app: axum::Router, + host: &str, + port: u16, + verbose: bool, + num_cpus: usize, +) -> tokio::task::JoinHandle> { + let addr = format!("{}:{}", host, port); + runtime.spawn(async move { let listener = tokio::net::TcpListener::bind(&addr).await?; if verbose { @@ -376,15 +490,53 @@ fn run_normal_mode( } axum::serve(listener, app).await - })?; + }) +} - #[allow(unreachable_code)] - Ok(()) +/// Block until shutdown is requested (abort the server, `Ok`) or the server +/// task ends on its own (return its result). +/// Complexity: 2 (Toyota Way: <10) +#[cfg(feature = "notebook")] +fn wait_for_shutdown_or_exit( + runtime: &tokio::runtime::Runtime, + mut server_handle: tokio::task::JoinHandle>, + shutdown_rx: &std::sync::mpsc::Receiver<()>, +) -> Result<()> { + loop { + if let Some(outcome) = poll_server(runtime, &mut server_handle, shutdown_rx) { + return outcome; + } + std::thread::sleep(std::time::Duration::from_millis(100)); + } +} + +/// One poll: `Some` once shutdown was requested or the server task ended. +/// Complexity: 3 (Toyota Way: <10) +#[cfg(feature = "notebook")] +fn poll_server( + runtime: &tokio::runtime::Runtime, + server_handle: &mut tokio::task::JoinHandle>, + shutdown_rx: &std::sync::mpsc::Receiver<()>, +) -> Option> { + if shutdown_rx.try_recv().is_ok() { + print_shutdown_message(); + server_handle.abort(); + return Some(Ok(())); + } + if !server_handle.is_finished() { + return None; + } + let joined = runtime.block_on(server_handle); + Some( + joined + .map_err(anyhow::Error::from) + .and_then(|served| Ok(served?)), + ) } #[cfg(not(feature = "notebook"))] pub fn handle_serve_command( - _directory: &Path, + directory: &Path, _port: u16, _host: &str, _verbose: bool, @@ -393,6 +545,7 @@ pub fn handle_serve_command( _pid_file: Option<&Path>, _watch_wasm: bool, ) -> Result<()> { + validate_serve_directory(directory)?; Err(anyhow::anyhow!( "HTTP server requires notebook feature. Rebuild with --features notebook" )) @@ -403,16 +556,219 @@ mod tests { use super::*; use tempfile::TempDir; + /// G2B-S2: a watcher that cannot be created must not end the server. #[test] - fn test_serve_handler_stub() { - // Serve handler tests require the notebook feature - // This is a placeholder + #[cfg(feature = "notebook")] + fn test_open_watcher_on_unwatchable_path_degrades_to_none() { + let dir = tempfile::tempdir().expect("tempdir"); + let missing = dir.path().join("absent"); + assert!(open_watcher(&missing, 100).is_none()); } - // ===== EXTREME TDD Round 145 - Serve Handler Tests ===== + #[test] + #[cfg(feature = "notebook")] + fn test_watch_unavailable_message_names_the_cause_and_remedy() { + let msg = watch_unavailable_message(Path::new("site"), &"Too many open files"); + assert!(msg.contains("--watch disabled"), "{msg}"); + assert!(msg.contains("site"), "{msg}"); + assert!(msg.contains("Too many open files"), "{msg}"); + assert!(msg.contains("max_user_instances"), "{msg}"); + } + /// A channel that has already received the shutdown request. + #[cfg(feature = "notebook")] + fn stopped() -> std::sync::mpsc::Receiver<()> { + let (tx, rx) = std::sync::mpsc::channel(); + tx.send(()).expect("send shutdown"); + rx + } + + /// Drive the serve path for these arguments without serving forever. + /// + /// With the notebook feature the server is set up (PID file, banner, app, + /// runtime, listener task, watcher) and stopped through the shutdown seam; + /// without it the stub is called directly. + fn serve_then_stop( + directory: &Path, + port: u16, + host: &str, + verbose: bool, + watch: bool, + debounce: u64, + pid_file: Option<&Path>, + watch_wasm: bool, + ) -> Result<()> { + #[cfg(feature = "notebook")] + { + let request = ServeRequest { + directory, + port, + host, + verbose, + watch, + debounce, + pid_file, + watch_wasm, + }; + serve_until(&request, stopped) + } + #[cfg(not(feature = "notebook"))] + handle_serve_command( + directory, port, host, verbose, watch, debounce, pid_file, watch_wasm, + ) + } + + /// A valid serve request shuts down cleanly with the notebook feature and + /// names the missing feature without it. + fn assert_served(result: Result<()>) { + #[cfg(feature = "notebook")] + assert!(result.is_ok(), "{result:?}"); + #[cfg(not(feature = "notebook"))] + assert!(result + .expect_err("stub must refuse") + .to_string() + .contains("notebook feature"),); + } + + /// A free local port (bound, then released). + #[cfg(feature = "notebook")] + fn free_port() -> u16 { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind port 0"); + listener.local_addr().expect("local addr").port() + } + + /// GET `path` from 127.0.0.1:`port`, retrying until the server listens. + #[cfg(feature = "notebook")] + fn http_get(port: u16, path: &str) -> String { + use std::io::{Read, Write}; + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20); + loop { + if let Ok(mut stream) = std::net::TcpStream::connect(("127.0.0.1", port)) { + let request = + format!("GET {path} HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n"); + stream.write_all(request.as_bytes()).expect("write request"); + let mut response = String::new(); + stream.read_to_string(&mut response).expect("read response"); + return response; + } + assert!( + std::time::Instant::now() < deadline, + "server never listened" + ); + std::thread::sleep(std::time::Duration::from_millis(50)); + } + } + + /// RHLGA-1: the server really serves the directory, then stops when the + /// shutdown channel receives, and the call returns. #[test] #[cfg(feature = "notebook")] + fn test_serve_until_serves_files_then_stops_on_shutdown() { + let temp_dir = TempDir::new().unwrap(); + std::fs::write(temp_dir.path().join("hello.txt"), "hello from serve").unwrap(); + let port = free_port(); + let (tx, rx) = std::sync::mpsc::channel(); + let client = std::thread::spawn(move || { + let response = http_get(port, "/hello.txt"); + tx.send(()).expect("send shutdown"); + response + }); + let request = ServeRequest { + directory: temp_dir.path(), + port, + host: "127.0.0.1", + verbose: false, + watch: false, + debounce: 100, + pid_file: None, + watch_wasm: false, + }; + serve_until(&request, move || rx).expect("serve until shutdown"); + let response = client.join().expect("client thread"); + assert!(response.starts_with("HTTP/1.1 200"), "{response}"); + assert!(response.contains("hello from serve"), "{response}"); + assert!( + response.contains("cross-origin-opener-policy: same-origin"), + "{response}" + ); + } + + /// RHLGA-1: an address that cannot be bound is reported, not waited on. + #[test] + #[cfg(feature = "notebook")] + fn test_serve_until_reports_a_port_in_use() { + let temp_dir = TempDir::new().unwrap(); + let taken = std::net::TcpListener::bind("127.0.0.1:0").expect("bind port 0"); + let port = taken.local_addr().expect("local addr").port(); + let (_tx, rx) = std::sync::mpsc::channel::<()>(); + let request = ServeRequest { + directory: temp_dir.path(), + port, + host: "127.0.0.1", + verbose: false, + watch: false, + debounce: 100, + pid_file: None, + watch_wasm: false, + }; + let err = serve_until(&request, move || rx).expect_err("port is taken"); + assert!(err.to_string().to_lowercase().contains("in use"), "{err}"); + } + + /// RHLGA-1: watch mode also stops when the shutdown channel receives. + #[test] + #[cfg(feature = "notebook")] + fn test_serve_until_watch_mode_stops_on_shutdown() { + let temp_dir = TempDir::new().unwrap(); + let request = ServeRequest { + directory: temp_dir.path(), + port: free_port(), + host: "127.0.0.1", + verbose: true, + watch: true, + debounce: 50, + pid_file: None, + watch_wasm: false, + }; + serve_until(&request, stopped).expect("watch mode stops"); + } + + /// RHLGA-1: watch mode on an address it cannot bind returns the bind error + /// promptly instead of watching files with no server behind them. + #[test] + #[cfg(feature = "notebook")] + fn test_serve_until_watch_mode_reports_a_port_in_use() { + let temp_dir = TempDir::new().unwrap(); + let taken = std::net::TcpListener::bind("127.0.0.1:0").expect("bind port 0"); + let port = taken.local_addr().expect("local addr").port(); + let dir = temp_dir.path().to_path_buf(); + let (done_tx, done_rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let (_tx, rx) = std::sync::mpsc::channel::<()>(); + let request = ServeRequest { + directory: &dir, + port, + host: "127.0.0.1", + verbose: false, + watch: true, + debounce: 50, + pid_file: None, + watch_wasm: false, + }; + let outcome = serve_until(&request, move || rx).map_err(|e| e.to_string()); + let _ = done_tx.send(outcome); + }); + let outcome = done_rx + .recv_timeout(std::time::Duration::from_secs(20)) + .expect("watch mode must return when the server cannot bind"); + let err = outcome.expect_err("port is taken"); + assert!(err.to_lowercase().contains("in use"), "{err}"); + drop(taken); + } + + // ===== EXTREME TDD Round 145 - Serve Handler Tests ===== + + #[test] fn test_handle_serve_command_nonexistent_dir() { let result = handle_serve_command( Path::new("/nonexistent/dir"), @@ -429,7 +785,6 @@ mod tests { } #[test] - #[cfg(feature = "notebook")] fn test_handle_serve_command_file_not_dir() { let temp_dir = TempDir::new().unwrap(); let file_path = temp_dir.path().join("test.txt"); @@ -483,22 +838,23 @@ mod tests { #[test] fn test_serve_command_default_parameters() { // Verify function signature accepts expected parameters - let _ = handle_serve_command( - Path::new("."), + let temp_dir = TempDir::new().unwrap(); + assert_served(serve_then_stop( + temp_dir.path(), 3000, "0.0.0.0", true, true, 1000, - Some(Path::new("/tmp/pid")), + Some(&temp_dir.path().join("server.pid")), true, - ); + )); } #[test] fn test_serve_command_with_verbose() { let temp_dir = TempDir::new().unwrap(); - let _ = handle_serve_command( + assert_served(serve_then_stop( temp_dir.path(), 8081, "127.0.0.1", @@ -507,14 +863,14 @@ mod tests { 500, None, false, - ); + )); } #[test] fn test_serve_command_various_hosts() { let temp_dir = TempDir::new().unwrap(); // Test localhost - let _ = handle_serve_command( + assert_served(serve_then_stop( temp_dir.path(), 8082, "localhost", @@ -523,9 +879,9 @@ mod tests { 100, None, false, - ); + )); // Test 0.0.0.0 - let _ = handle_serve_command( + assert_served(serve_then_stop( temp_dir.path(), 8083, "0.0.0.0", @@ -534,13 +890,13 @@ mod tests { 100, None, false, - ); + )); } #[test] fn test_serve_command_various_ports() { let temp_dir = TempDir::new().unwrap(); - let _ = handle_serve_command( + assert_served(serve_then_stop( temp_dir.path(), 80, "127.0.0.1", @@ -549,8 +905,8 @@ mod tests { 100, None, false, - ); - let _ = handle_serve_command( + )); + assert_served(serve_then_stop( temp_dir.path(), 443, "127.0.0.1", @@ -559,8 +915,8 @@ mod tests { 100, None, false, - ); - let _ = handle_serve_command( + )); + assert_served(serve_then_stop( temp_dir.path(), 65535, "127.0.0.1", @@ -569,14 +925,14 @@ mod tests { 100, None, false, - ); + )); } #[test] fn test_serve_command_debounce_values() { let temp_dir = TempDir::new().unwrap(); // Minimum debounce - let _ = handle_serve_command( + assert_served(serve_then_stop( temp_dir.path(), 8084, "127.0.0.1", @@ -585,9 +941,9 @@ mod tests { 1, // 1ms None, false, - ); + )); // Large debounce - let _ = handle_serve_command( + assert_served(serve_then_stop( temp_dir.path(), 8085, "127.0.0.1", @@ -596,13 +952,13 @@ mod tests { 10000, // 10s None, false, - ); + )); } #[test] fn test_serve_command_with_wasm_watch() { let temp_dir = TempDir::new().unwrap(); - let _ = handle_serve_command( + assert_served(serve_then_stop( temp_dir.path(), 8086, "127.0.0.1", @@ -611,7 +967,7 @@ mod tests { 500, None, true, // watch_wasm - ); + )); } // ===== EXTREME TDD Round 153 - Serve Handler Tests ===== @@ -620,7 +976,7 @@ mod tests { fn test_serve_command_all_flags() { let temp_dir = TempDir::new().unwrap(); let pid_path = temp_dir.path().join("server.pid"); - let _ = handle_serve_command( + assert_served(serve_then_stop( temp_dir.path(), 8087, "127.0.0.1", @@ -629,13 +985,13 @@ mod tests { 250, // debounce Some(&pid_path), true, // watch_wasm - ); + )); } #[test] fn test_serve_command_zero_debounce() { let temp_dir = TempDir::new().unwrap(); - let _ = handle_serve_command( + assert_served(serve_then_stop( temp_dir.path(), 8088, "127.0.0.1", @@ -644,7 +1000,7 @@ mod tests { 0, // zero debounce None, false, - ); + )); } #[test] @@ -652,6 +1008,15 @@ mod tests { let temp_dir = TempDir::new().unwrap(); let nested = temp_dir.path().join("a").join("b").join("c"); std::fs::create_dir_all(&nested).unwrap(); - let _ = handle_serve_command(&nested, 8089, "127.0.0.1", false, false, 100, None, false); + assert_served(serve_then_stop( + &nested, + 8089, + "127.0.0.1", + false, + false, + 100, + None, + false, + )); } } diff --git a/src/bin/ruchy_tests.rs b/src/bin/ruchy_tests.rs index cf0fc874f..d9d0b525c 100644 --- a/src/bin/ruchy_tests.rs +++ b/src/bin/ruchy_tests.rs @@ -205,8 +205,12 @@ fn test_handle_advanced_command_repl() { record: None, max_depth: 100, }; + // Main routes this verb before the router; the router must refuse it + // rather than report a silent success (G2B-S2). Running it here would + // publish or block on stdin; its handler has its own tests. let result = handle_advanced_command(command); - assert!(result.is_ok()); + let err = result.expect_err("the router must not accept a main-routed verb"); + assert!(err.to_string().contains("routing"), "{err}"); } #[test] @@ -217,7 +221,8 @@ fn test_handle_advanced_command_parse() { let command = Commands::Parse { file: temp_file.path().to_path_buf(), }; - let result = handle_advanced_command(command); + // Main routes this verb itself; exercise that real route. + let result = handle_command_dispatch(Some(command), false, VmMode::Ast); assert!(result.is_ok()); } @@ -232,7 +237,8 @@ fn test_handle_advanced_command_transpile() { minimal: false, emit: None, }; - let result = handle_advanced_command(command); + // Main routes this verb itself; exercise that real route. + let result = handle_command_dispatch(Some(command), false, VmMode::Ast); assert!(result.is_ok()); } @@ -241,9 +247,10 @@ fn test_handle_advanced_command_compile() { let temp_file = NamedTempFile::new().expect("Failed to create temporary test file"); fs::write(&temp_file, "let x = 42").expect("Failed to write test content to temporary file"); + let out_dir = tempfile::TempDir::new().expect("temp dir"); let command = Commands::Compile { file: temp_file.path().to_path_buf(), - output: PathBuf::from("test.out"), + output: out_dir.path().join("test.out"), opt_level: "2".to_string(), optimize: None, strip: false, @@ -255,7 +262,8 @@ fn test_handle_advanced_command_compile() { pgo: false, embed_models: Vec::new(), }; - let result = handle_advanced_command(command); + // Main routes this verb itself; exercise that real route. + let result = handle_command_dispatch(Some(command), false, VmMode::Ast); assert!(result.is_ok()); } @@ -269,7 +277,8 @@ fn test_handle_advanced_command_check() { watch: false, format: "text".to_string(), }; - let result = handle_advanced_command(command); + // Main routes this verb itself; exercise that real route. + let result = handle_command_dispatch(Some(command), false, VmMode::Ast); assert!(result.is_ok()); } @@ -423,8 +432,12 @@ fn test_handle_advanced_command_publish() { dry_run: true, allow_dirty: false, }; + // Main routes this verb before the router; the router must refuse it + // rather than report a silent success (G2B-S2). Running it here would + // publish or block on stdin; its handler has its own tests. let result = handle_advanced_command(command); - assert!(result.is_ok()); + let err = result.expect_err("the router must not accept a main-routed verb"); + assert!(err.to_string().contains("routing"), "{err}"); } #[test] diff --git a/src/frontend/ast.rs b/src/frontend/ast.rs index 7be0fdceb..a29c7b00e 100644 --- a/src/frontend/ast.rs +++ b/src/frontend/ast.rs @@ -274,7 +274,21 @@ pub struct Expr { /// At Silver level (default), they transpile to `debug_assert!` in Rust. pub contracts: Vec, } +/// Attribute name the parser attaches to a `FieldAccess` written with `::` +/// (`module::function`, `Type::assoc`). It is not spellable in source, so a +/// `.` access (`obj.field`) never carries it. Consumers that do not care about +/// the distinction (the interpreter) see an ordinary `FieldAccess`. +pub const PATH_ACCESS_MARKER: &str = "::"; + impl Expr { + /// True when this expression is a `FieldAccess` written with `::` (a path + /// segment), false for `obj.field` and every other expression. + #[must_use] + pub fn is_path_access(&self) -> bool { + matches!(self.kind, ExprKind::FieldAccess { .. }) + && self.attributes.iter().any(|a| a.name == PATH_ACCESS_MARKER) + } + /// Creates a new expression with the given kind and span. /// /// This is the primary constructor for building AST nodes. The expression diff --git a/src/frontend/parser/expressions_helpers/enums.rs b/src/frontend/parser/expressions_helpers/enums.rs index 3772f3d95..d8eb1c7ad 100644 --- a/src/frontend/parser/expressions_helpers/enums.rs +++ b/src/frontend/parser/expressions_helpers/enums.rs @@ -246,13 +246,17 @@ fn parse_variant_struct_fields(state: &mut ParserState) -> Result { + state.tokens.advance(); + name + } + None => bail!("Expected field name in struct variant"), }; // Expect colon diff --git a/src/frontend/parser/expressions_helpers/modules.rs b/src/frontend/parser/expressions_helpers/modules.rs index 181c9928c..96658aa37 100644 --- a/src/frontend/parser/expressions_helpers/modules.rs +++ b/src/frontend/parser/expressions_helpers/modules.rs @@ -29,7 +29,7 @@ //! //! Extracted from expressions.rs to improve maintainability (TDG Structural improvement). -use crate::frontend::ast::{Attribute, Expr, ExprKind, Span}; +use crate::frontend::ast::{Expr, ExprKind, Span}; use crate::frontend::lexer::Token; use crate::frontend::parser::{bail, ParserState, Result}; @@ -88,8 +88,7 @@ fn parse_module_body(state: &mut ParserState) -> Result { let mut exprs = Vec::new(); while !matches!(state.tokens.peek(), Some((Token::RightBrace, _))) { - let is_pub = parse_visibility_modifier(state); - exprs.push(parse_module_item(state, is_pub)?); + exprs.push(parse_module_item(state)?); skip_optional_semicolon(state); } @@ -97,39 +96,20 @@ fn parse_module_body(state: &mut ParserState) -> Result { Ok(Expr::new(ExprKind::Block(exprs), start_span)) } -/// Parse visibility modifier (pub) -fn parse_visibility_modifier(state: &mut ParserState) -> bool { - if matches!(state.tokens.peek(), Some((Token::Pub, _))) { - state.tokens.advance(); - true - } else { - false - } -} - -/// Parse module item (function, use statement, nested module, or expression) -fn parse_module_item(state: &mut ParserState, is_pub: bool) -> Result { +/// Parse module item: a `pub` item or a private item/expression. +/// +/// A `pub` item inside a module is parsed exactly like one at the top level +/// (`pub fun`, `pub struct`, `pub enum`, `pub mod`, `pub use`, `pub(crate) ..`), +/// so nested modules and public types are accepted (NESTEDMOD-1). +fn parse_module_item(state: &mut ParserState) -> Result { match state.tokens.peek() { + Some((Token::Pub, span)) => { + let span = *span; + super::visibility_modifiers::parse_pub_token(state, span) + } // DEFECT-PARSER-015 FIX: Accept both 'fun' and 'fn' for functions Some((Token::Fun | Token::Fn, _)) => { - crate::frontend::parser::functions::parse_function_with_visibility(state, is_pub) - } - Some((Token::Use, _)) if is_pub => { - state.tokens.advance(); - crate::frontend::parser::parse_use_statement_with_visibility(state, true) - } - // DEFECT-PARSER-015 FIX: Allow pub mod - Some((Token::Mod | Token::Module, _)) if is_pub => { - let mut expr = parse_module_declaration(state)?; - expr.attributes.push(Attribute { - name: "pub".to_string(), - args: vec![], - span: expr.span, - }); - Ok(expr) - } - _ if is_pub => { - bail!("'pub' can only be used with function declarations, use statements, or module declarations") + crate::frontend::parser::functions::parse_function_with_visibility(state, false) } _ => crate::frontend::parser::parse_expr_recursive(state), } diff --git a/src/frontend/parser/expressions_helpers/structs.rs b/src/frontend/parser/expressions_helpers/structs.rs index 04ad4c4fa..de15a5339 100644 --- a/src/frontend/parser/expressions_helpers/structs.rs +++ b/src/frontend/parser/expressions_helpers/structs.rs @@ -227,12 +227,17 @@ fn parse_private_keyword(state: &mut ParserState) { pub(in crate::frontend::parser) fn parse_single_struct_field( state: &mut ParserState, ) -> Result<(String, Type, Option)> { - let field_name = if let Some((Token::Identifier(n), _)) = state.tokens.peek() { - let name = n.clone(); - state.tokens.advance(); - name - } else { - bail!("Expected field name"); + // RESFIELD-1: a keyword is a field name too (`type: i32`), as after '.' + let field_name = match state + .tokens + .peek() + .and_then(|(token, _)| crate::frontend::parser::member_names::member_name(token)) + { + Some(name) => { + state.tokens.advance(); + name + } + None => bail!("Expected field name"), }; state.tokens.expect(&Token::Colon)?; diff --git a/src/frontend/parser/functions.rs b/src/frontend/parser/functions.rs index ff76506e5..1b7d56dfc 100644 --- a/src/frontend/parser/functions.rs +++ b/src/frontend/parser/functions.rs @@ -443,64 +443,46 @@ pub fn parse_method_call(state: &mut ParserState, receiver: Expr) -> Result { - let method = name.clone(); - state.tokens.advance(); - parse_method_or_field_access(state, receiver, method) - } - Some((Token::Send, _)) => { - // Handle 'send' as a method name (for actors) - state.tokens.advance(); - parse_method_or_field_access(state, receiver, "send".to_string()) - } - Some((Token::Ask, _)) => { - // Handle 'ask' as a method name (for actors) - state.tokens.advance(); - parse_method_or_field_access(state, receiver, "ask".to_string()) - } Some((Token::Integer(index), _)) => { // Handle tuple access like t.0, t.1, etc. let index = index.clone(); state.tokens.advance(); - Ok(Expr { - kind: ExprKind::FieldAccess { - object: Box::new(receiver), - field: index, - }, - span: Span { start: 0, end: 0 }, - attributes: Vec::new(), - leading_comments: Vec::new(), - trailing_comment: None, - contracts: Vec::new(), - }) + Ok(create_field_access(receiver, index, name_end)) } _ => { bail!("Expected method name, tuple index, or 'await' after '.'"); } } } +/// Member name at the cursor after `.`/`?.`, without consuming it (complexity: 1) +fn peek_member_name(state: &mut ParserState) -> Option { + state + .tokens + .peek() + .and_then(|(token, _)| super::member_names::member_name(token)) +} pub fn parse_optional_method_call(state: &mut ParserState, receiver: Expr) -> Result { // Skip any comments between '?.' and method name (PARSER-053) state.skip_comments(); // Parse method name or tuple index for optional chaining + // EXTENDKW-1: an identifier or any keyword (`extend`, `type`, `match`, ...) + if let Some(method) = peek_member_name(state) { + state.tokens.advance(); + return parse_optional_method_or_field_access(state, receiver, method); + } match state.tokens.peek() { - Some((Token::Identifier(name), _)) => { - let method = name.clone(); - state.tokens.advance(); - parse_optional_method_or_field_access(state, receiver, method) - } - Some((Token::Send, _)) => { - // Handle 'send' as a method name (for actors) - state.tokens.advance(); - parse_optional_method_or_field_access(state, receiver, "send".to_string()) - } - Some((Token::Ask, _)) => { - // Handle 'ask' as a method name (for actors) - state.tokens.advance(); - parse_optional_method_or_field_access(state, receiver, "ask".to_string()) - } Some((Token::Integer(index), _)) => { // Handle optional tuple access like t?.0, t?.1, etc. let index = index.clone(); @@ -526,6 +508,7 @@ fn parse_method_or_field_access( state: &mut ParserState, receiver: Expr, method: String, + name_end: usize, ) -> Result { // PARSER-069 FIX: Check for turbofish generics (::) before checking for method call // Example: "42".parse::() has :: after parse, not ( @@ -551,7 +534,7 @@ fn parse_method_or_field_access( parse_method_call_access(state, receiver, method_name) } else { // Field access - Ok(create_field_access(receiver, method_name)) + Ok(create_field_access(receiver, method_name, name_end)) } } /// Parse method call with arguments (complexity: 6) @@ -757,14 +740,18 @@ fn create_method_call(receiver: Expr, method: String, args: Vec) -> Expr { contracts: Vec::new(), } } -/// Create a field access expression (complexity: 1) -fn create_field_access(receiver: Expr, field: String) -> Expr { +/// Create a field access expression spanning `receiver.field` (complexity: 1) +/// +/// The span must end at the field name: a postfix call `(obj.f)(x)` is only +/// taken when the `(` is on the same line as the end of its callee (PARENCALL-1). +fn create_field_access(receiver: Expr, field: String, field_end: usize) -> Expr { + let span = Span::new(receiver.span.start.min(field_end), field_end); Expr { kind: ExprKind::FieldAccess { object: Box::new(receiver), field, }, - span: Span { start: 0, end: 0 }, + span, attributes: Vec::new(), leading_comments: Vec::new(), trailing_comment: None, diff --git a/src/frontend/parser/member_names.rs b/src/frontend/parser/member_names.rs new file mode 100644 index 000000000..96869ea62 --- /dev/null +++ b/src/frontend/parser/member_names.rs @@ -0,0 +1,141 @@ +//! EXTENDKW-1: member names after `.` and `?.`. +//! +//! A keyword token in member position (`v.extend(x)`, `o.type`, `o.match(p)`) +//! names a method or field; it is spelled as its source text. `await` is not +//! listed: `x.await` is an await expression and is handled before this lookup. +//! `self`/`super`/`crate`/`Self` are not listed: Rust cannot name a member +//! with them, not even as a raw identifier. +use super::Token; + +/// Keyword tokens accepted as a member name, paired with their source text. +static KEYWORD_MEMBERS: &[(Token, &str)] = &[ + (Token::Extend, "extend"), + (Token::Match, "match"), + (Token::Type, "type"), + (Token::Loop, "loop"), + (Token::Impl, "impl"), + (Token::Mod, "mod"), + (Token::Use, "use"), + (Token::As, "as"), + (Token::In, "in"), + (Token::Where, "where"), + (Token::Static, "static"), + (Token::Const, "const"), + (Token::Trait, "trait"), + (Token::Struct, "struct"), + (Token::Enum, "enum"), + (Token::Try, "try"), + (Token::Yield, "yield"), + (Token::Final, "final"), + (Token::Abstract, "abstract"), + (Token::Override, "override"), + (Token::Async, "async"), + (Token::Return, "return"), + (Token::Break, "break"), + (Token::Continue, "continue"), + (Token::If, "if"), + (Token::Else, "else"), + (Token::For, "for"), + (Token::While, "while"), + (Token::Let, "let"), + (Token::Fn, "fn"), + (Token::Pub, "pub"), + (Token::Mut, "mut"), + (Token::Unsafe, "unsafe"), + (Token::From, "from"), + (Token::Default, "default"), + (Token::Module, "module"), + (Token::Var, "var"), + (Token::With, "with"), + (Token::Import, "import"), + (Token::Export, "export"), + (Token::Class, "class"), + (Token::Handle, "handle"), + (Token::Handler, "handler"), + (Token::Effect, "effect"), + (Token::Property, "property"), + (Token::Private, "private"), + (Token::Protected, "protected"), + (Token::Sealed, "sealed"), + (Token::Mixin, "mixin"), + (Token::Operator, "operator"), + (Token::Interface, "interface"), + (Token::Implements, "implements"), + (Token::Receive, "receive"), + (Token::Spawn, "spawn"), + (Token::Actor, "actor"), + (Token::Lazy, "lazy"), + (Token::Catch, "catch"), + (Token::Finally, "finally"), + (Token::Throw, "throw"), + (Token::Signal, "signal"), + (Token::Infra, "infra"), + (Token::Requires, "requires"), + (Token::Ensures, "ensures"), + (Token::Invariant_, "invariant"), + (Token::Decreases, "decreases"), + (Token::Fun, "fun"), + (Token::Send, "send"), + (Token::Ask, "ask"), +]; + +/// Source text of a keyword token usable as a member name (complexity: 1). +fn keyword_member_name(token: &Token) -> Option<&'static str> { + KEYWORD_MEMBERS + .iter() + .find(|(keyword, _)| keyword == token) + .map(|(_, name)| *name) +} + +/// Member name spelled by `token`: an identifier or a keyword (complexity: 2). +pub(super) fn member_name(token: &Token) -> Option { + match token { + Token::Identifier(name) => Some(name.clone()), + other => keyword_member_name(other).map(str::to_string), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extendkw_1_member_name_identifier() { + assert_eq!( + member_name(&Token::Identifier("len".to_string())), + Some("len".to_string()) + ); + } + + #[test] + fn test_extendkw_1_member_name_keywords() { + assert_eq!(member_name(&Token::Extend), Some("extend".to_string())); + assert_eq!(member_name(&Token::Match), Some("match".to_string())); + assert_eq!( + member_name(&Token::Invariant_), + Some("invariant".to_string()) + ); + } + + #[test] + fn test_extendkw_1_member_name_rejects_await_and_paths() { + for token in [ + Token::Await, + Token::Self_, + Token::Super, + Token::Crate, + Token::Integer("0".to_string()), + ] { + assert_eq!(member_name(&token), None, "{token:?}"); + } + } + + #[test] + fn test_extendkw_1_keyword_member_spelling_matches_lexer() { + for (token, name) in KEYWORD_MEMBERS { + let mut stream = crate::frontend::lexer::TokenStream::new(name); + let first = stream.next().map(|(t, _)| t); + assert_eq!(first.as_ref(), Some(token), "{name}"); + } + } +} diff --git a/src/frontend/parser/mod.rs b/src/frontend/parser/mod.rs index f5e0a38e0..097e01388 100644 --- a/src/frontend/parser/mod.rs +++ b/src/frontend/parser/mod.rs @@ -51,6 +51,7 @@ mod expressions; mod functions; mod imports; mod macro_parsing; +mod member_names; mod operator_precedence; mod types; mod utils; @@ -585,13 +586,19 @@ fn handle_colon_colon_operator(state: &mut ParserState, left: Expr) -> Result diff --git a/src/frontend/parser/types.rs b/src/frontend/parser/types.rs index 6abbb7377..25c9ed739 100644 --- a/src/frontend/parser/types.rs +++ b/src/frontend/parser/types.rs @@ -17,13 +17,18 @@ fn parse_struct_base(state: &mut ParserState) -> Result>> { } // Helper: Parse field name identifier (complexity: 2) +// RESFIELD-1: a keyword is a field name in a struct literal too (`type: 5`) fn parse_field_name(state: &mut ParserState) -> Result { - if let Some((Token::Identifier(name), _)) = state.tokens.peek() { - let name = name.clone(); - state.tokens.advance(); - Ok(name) - } else { - bail!("Expected field name"); + let name = state + .tokens + .peek() + .and_then(|(token, _)| crate::frontend::parser::member_names::member_name(token)); + match name { + Some(name) => { + state.tokens.advance(); + Ok(name) + } + None => bail!("Expected field name"), } } diff --git a/src/quality/formatter.rs b/src/quality/formatter.rs index 1f9e26a24..485e67287 100644 --- a/src/quality/formatter.rs +++ b/src/quality/formatter.rs @@ -280,7 +280,7 @@ impl Formatter { } // Format the expression itself - let expr_str = self.format_expr_kind(&expr.kind, &indent_str, indent); + let expr_str = self.format_expr_node(expr, &indent_str, indent); // Append the formatted expression result.push_str(&expr_str); @@ -296,6 +296,17 @@ impl Formatter { // --- Extracted helpers for format_expr (CB-200 reduction) --- + /// RHLGA-1: a `FieldAccess` the parser marked as written with `::` is a path + /// segment; printing it with `.` would reparse as a method call or field. + fn format_expr_node(&self, expr: &Expr, indent_str: &str, indent: usize) -> String { + match &expr.kind { + ExprKind::FieldAccess { object, field } if expr.is_path_access() => { + format!("{}::{}", self.format_expr(object, indent), field) + } + kind => self.format_expr_kind(kind, indent_str, indent), + } + } + fn format_expr_kind(&self, kind: &ExprKind, indent_str: &str, indent: usize) -> String { match kind { ExprKind::Literal(lit) => self.format_literal_expr(lit), diff --git a/src/runtime/builtin_init.rs b/src/runtime/builtin_init.rs index e12d4e878..097d0f536 100644 --- a/src/runtime/builtin_init.rs +++ b/src/runtime/builtin_init.rs @@ -123,6 +123,15 @@ fn add_io_functions(global_env: &mut HashMap) { "print".to_string(), Value::from_string("__builtin_print__".to_string()), ); + // EPRINTLN-1: stderr counterparts of println/print + global_env.insert( + "eprintln".to_string(), + Value::from_string("__builtin_eprintln__".to_string()), + ); + global_env.insert( + "eprint".to_string(), + Value::from_string("__builtin_eprint__".to_string()), + ); global_env.insert( "dbg".to_string(), Value::from_string("__builtin_dbg__".to_string()), @@ -814,7 +823,8 @@ mod tests { // parse_json alias: parse_json (Issue #131 - v3.182.0) // +1 dir() builtin (object-inspection-consistency spec) // +1 assert_ne() builtin (METHODS-1) - assert_eq!(env.len(), 118); + // +2 eprintln()/eprint() builtins (EPRINTLN-1) + assert_eq!(env.len(), 120); } #[test] @@ -898,7 +908,9 @@ mod tests { assert!(env.contains_key("println")); assert!(env.contains_key("print")); assert!(env.contains_key("dbg")); - assert_eq!(env.len(), 3); + assert!(env.contains_key("eprintln")); + assert!(env.contains_key("eprint")); + assert_eq!(env.len(), 5); } #[test] diff --git a/src/runtime/eval_array.rs b/src/runtime/eval_array.rs index dc3db687f..4d3bfc888 100644 --- a/src/runtime/eval_array.rs +++ b/src/runtime/eval_array.rs @@ -18,7 +18,14 @@ fn eval_array_nullary_method( method: &str, ) -> Option> { let result = match method { - "len" | "length" => eval_array_len(arr), + // COUNTITER-1: `count()` on an array, or on what `chars()`, `bytes()` + // and `iter()` return (arrays in the interpreter), is its length + "len" | "length" | "count" => eval_array_len(arr), + // COUNTITER-1: `iter()` is the array itself, so `v.iter().count()` works + "iter" => Ok(Value::Array(Arc::clone(arr))), + // ARRCLONE-1: a copy of the array; writes to either copy the shared + // storage first, so the copies stay independent + "clone" | "to_vec" | "to_owned" => Ok(Value::Array(Arc::clone(arr))), "first" => eval_array_first(arr), "last" => eval_array_last(arr), "is_empty" => eval_array_is_empty(arr), @@ -57,6 +64,7 @@ fn eval_array_unary_method( // METHODS-1: `drop(n)` is the array without its first n elements "skip" | "drop" => eval_array_skip(arr, arg), "zip" => eval_array_zip(arr, arg), + "repeat" => eval_array_repeat(arr, arg), _ => return None, }; Some(result) @@ -110,6 +118,169 @@ where } } +/// ARRAYMUT-1: array methods that change their receiver in place, as `Vec` +/// methods do in Rust. The interpreter applies them to a local array or a +/// field and writes the changed array back (see `apply_in_place_array_method`). +/// +/// # Complexity +/// Cyclomatic complexity: 1 +pub(crate) fn is_in_place_array_method(method: &str, arg_count: usize) -> bool { + matches!( + (method, arg_count), + ( + "push" | "append" | "extend" | "extend_from_slice" | "remove" | "truncate", + 1 + ) | ("pop" | "sort" | "reverse" | "clear" | "dedup", 0) + | ("insert" | "resize", 2) + ) +} + +/// ARRAYMUT-1: apply an in-place method to `items`, returning what the Rust +/// `Vec` method returns: the removed element for `remove`, the popped element +/// (or `nil` when empty) for `pop`, and `nil` for every other method. +/// +/// # Errors +/// Out-of-range `insert`/`remove`, a non-integer index, a non-iterable +/// `append`/`extend` argument, or a method outside `is_in_place_array_method`. +/// +/// # Complexity +/// Cyclomatic complexity: 2 +pub(crate) fn apply_in_place_array_method( + items: &mut Vec, + method: &str, + args: &[Value], +) -> Result { + match args { + [] => apply_in_place_nullary(items, method), + _ => apply_in_place_with_args(items, method, args), + } +} + +/// # Complexity +/// Cyclomatic complexity: 7 +fn apply_in_place_nullary(items: &mut Vec, method: &str) -> Result { + match method { + "pop" => return Ok(items.pop().unwrap_or(Value::Nil)), + "sort" => items.sort_by(compare_for_sort), + "reverse" => items.reverse(), + "clear" => items.clear(), + "dedup" => items.dedup_by(|a, b| values_equal(a, b)), + _ => return Err(unknown_in_place(method)), + } + Ok(Value::Nil) +} + +/// # Complexity +/// Cyclomatic complexity: 7 +fn apply_in_place_with_args( + items: &mut Vec, + method: &str, + args: &[Value], +) -> Result { + match (method, args) { + ("push", [item]) => items.push(item.clone()), + ("append" | "extend" | "extend_from_slice", [other]) => { + items.extend(iterable_items(method, other)?); + } + ("remove", [index]) => return remove_at(items, index), + ("truncate", [len]) => items.truncate(index_arg("truncate", len)?), + ("insert", [index, item]) => insert_at(items, index, item)?, + // LETVEC-1: resize(n, x) pads with clones of x or truncates to n + ("resize", [len, item]) => items.resize(index_arg("resize", len)?, item.clone()), + _ => return Err(unknown_in_place(method)), + } + Ok(Value::Nil) +} + +fn unknown_in_place(method: &str) -> InterpreterError { + InterpreterError::RuntimeError(format!("Unknown array method: {method}")) +} + +/// A non-negative integer argument used as an index or length. +/// +/// # Complexity +/// Cyclomatic complexity: 3 +fn index_arg(method: &str, value: &Value) -> Result { + match value { + Value::Integer(i) => usize::try_from(*i).map_err(|_| { + InterpreterError::RuntimeError(format!("{method}: index must be non-negative, got {i}")) + }), + other => Err(InterpreterError::RuntimeError(format!( + "{method}: index must be an integer, got {}", + other.type_name() + ))), + } +} + +/// # Complexity +/// Cyclomatic complexity: 2 +fn remove_at(items: &mut Vec, index: &Value) -> Result { + let i = index_arg("remove", index)?; + if i >= items.len() { + return Err(InterpreterError::RuntimeError(format!( + "remove: index {i} out of range for array of length {}", + items.len() + ))); + } + Ok(items.remove(i)) +} + +/// # Complexity +/// Cyclomatic complexity: 2 +fn insert_at(items: &mut Vec, index: &Value, item: &Value) -> Result<(), InterpreterError> { + let i = index_arg("insert", index)?; + if i > items.len() { + return Err(InterpreterError::RuntimeError(format!( + "insert: index {i} out of range for array of length {}", + items.len() + ))); + } + items.insert(i, item.clone()); + Ok(()) +} + +/// The elements an `append`/`extend` argument contributes: an array's or a +/// tuple's elements, or the integers of an integer range. +/// +/// # Complexity +/// Cyclomatic complexity: 4 +fn iterable_items(method: &str, value: &Value) -> Result, InterpreterError> { + match value { + Value::Array(arr) => Ok(arr.to_vec()), + Value::Tuple(elems) => Ok(elems.to_vec()), + Value::Range { + start, + end, + inclusive, + } => range_items(method, start, end, *inclusive), + other => Err(InterpreterError::RuntimeError(format!( + "{method}: expected an array, got {}", + other.type_name() + ))), + } +} + +/// # Complexity +/// Cyclomatic complexity: 3 +fn range_items( + method: &str, + start: &Value, + end: &Value, + inclusive: bool, +) -> Result, InterpreterError> { + let (Value::Integer(lo), Value::Integer(hi)) = (start, end) else { + return Err(InterpreterError::RuntimeError(format!( + "{method}: expected an integer range" + ))); + }; + let values: Vec = if inclusive { + (*lo..=*hi).map(Value::Integer).collect() + } else { + (*lo..*hi).map(Value::Integer).collect() + }; + Ok(values) +} + // No-argument array methods (complexity <= 3 each) fn eval_array_len(arr: &Arc<[Value]>) -> Result { @@ -271,6 +442,21 @@ fn eval_array_join(arr: &Arc<[Value]>, separator: &Value) -> Result, count: &Value) -> Result { + let times = match count { + Value::Integer(n) => usize::try_from(*n).ok(), + _ => None, + } + .ok_or_else(|| { + InterpreterError::RuntimeError(format!( + "Array.repeat() expects a non-negative integer, got {count}" + )) + })?; + let items = (0..times).flat_map(|_| arr.iter().cloned()).collect(); + Ok(Value::from_array(items)) +} + /// Remove duplicate elements from array /// Complexity: 3 (within Toyota Way limits) fn eval_array_unique(arr: &Arc<[Value]>) -> Result { @@ -1074,3 +1260,105 @@ mod sort_order_tests { } } } + +/// ARRAYMUT-1: in-place methods change the receiver and return Rust's result. +/// `extend` is covered here because `.extend(` does not parse yet (EXTENDKW-1). +#[cfg(test)] +mod in_place_tests { + use super::{apply_in_place_array_method, is_in_place_array_method}; + use crate::runtime::Value; + use std::sync::Arc; + + fn ints(values: &[i64]) -> Vec { + values.iter().copied().map(Value::Integer).collect() + } + + fn apply(items: &mut Vec, method: &str, args: &[Value]) -> Value { + apply_in_place_array_method(items, method, args).expect("in-place method succeeds") + } + + #[test] + fn test_arraymut_1_extend_with_array_appends_elements() { + let mut items = ints(&[1, 2]); + let other = Value::Array(Arc::from(ints(&[3, 4]))); + assert_eq!(apply(&mut items, "extend", &[other]), Value::Nil); + assert_eq!(items, ints(&[1, 2, 3, 4])); + } + + #[test] + fn test_arraymut_1_extend_with_ranges_appends_integers() { + let mut items = ints(&[0]); + apply( + &mut items, + "extend", + &[Value::from_range( + Value::Integer(1), + Value::Integer(3), + false, + )], + ); + apply( + &mut items, + "extend", + &[Value::from_range( + Value::Integer(7), + Value::Integer(8), + true, + )], + ); + assert_eq!(items, ints(&[0, 1, 2, 7, 8])); + } + + #[test] + fn test_arraymut_1_extend_with_tuple_appends_elements() { + let mut items = Vec::new(); + let tuple = Value::Tuple(Arc::from(ints(&[5, 6]))); + apply(&mut items, "extend", &[tuple]); + assert_eq!(items, ints(&[5, 6])); + } + + #[test] + fn test_arraymut_1_extend_rejects_non_iterable() { + let mut items = ints(&[1]); + let err = apply_in_place_array_method(&mut items, "extend", &[Value::Integer(3)]); + assert!(err.is_err()); + assert_eq!(items, ints(&[1])); + } + + #[test] + fn test_arraymut_1_pop_and_push_keep_their_results() { + let mut items = ints(&[1]); + assert_eq!(apply(&mut items, "push", &[Value::Integer(2)]), Value::Nil); + assert_eq!(apply(&mut items, "pop", &[]), Value::Integer(2)); + assert_eq!(apply(&mut items, "pop", &[]), Value::Integer(1)); + assert_eq!(apply(&mut items, "pop", &[]), Value::Nil); + } + + #[test] + fn test_arraymut_1_bad_indices_are_errors() { + let mut items = ints(&[1, 2]); + let bad = [ + ("remove", vec![Value::Integer(2)]), + ("remove", vec![Value::Integer(-1)]), + ("insert", vec![Value::Integer(3), Value::Integer(0)]), + ("truncate", vec![Value::from_string("x".to_string())]), + ]; + for (method, args) in bad { + assert!( + apply_in_place_array_method(&mut items, method, &args).is_err(), + "{method}" + ); + } + assert_eq!(items, ints(&[1, 2])); + } + + #[test] + fn test_arraymut_1_table_matches_arity() { + assert!(is_in_place_array_method("insert", 2)); + assert!(!is_in_place_array_method("insert", 1)); + assert!(is_in_place_array_method("sort", 0)); + assert!(!is_in_place_array_method("sort", 1)); + assert!(!is_in_place_array_method("sorted", 0)); + assert!(!is_in_place_array_method("reversed", 0)); + } +} diff --git a/src/runtime/eval_builtin.rs b/src/runtime/eval_builtin.rs index a7453ea3e..ceb2ab002 100644 --- a/src/runtime/eval_builtin.rs +++ b/src/runtime/eval_builtin.rs @@ -11,11 +11,11 @@ //! - `eval_builtin_json_ops`: JSON operations (parse, stringify, merge, get, set) //! - `eval_builtin_platform`: HTTP, HTML, Process, File, String, and type conversions +use crate::runtime::interpreter_format_calls::{eval_builtin_format, FormatSink}; use crate::runtime::validation::validate_arg_count; use crate::runtime::{InterpreterError, Value}; use std::collections::HashMap; -use std::io::Write; use std::sync::Arc; // Re-export from sub-modules so that `use super::*` in test modules still works @@ -78,7 +78,11 @@ fn try_eval_io_function(name: &str, args: &[Value]) -> Result, Int "__builtin_println__" => Ok(Some(eval_println(args)?)), "__builtin_print__" => Ok(Some(eval_print(args)?)), "__builtin_dbg__" => Ok(Some(eval_dbg(args)?)), - _ => Ok(None), + // FMTSPEC-1 / EPRINTLN-1 / FORMATFN-1: one format engine for all five + _ => match FormatSink::for_marker(name) { + Some(sink) => Ok(Some(eval_builtin_format(sink, args)?)), + None => Ok(None), + }, } } @@ -282,35 +286,25 @@ fn try_eval_dataframe_function( } } -/// Print values to stdout with newline -/// -/// Supports printf-style formatting with {} placeholders: -/// - `println("Count: {}", 42)` → "Count: 42" -/// - `println("Name: {}, Age: {}", "Alice", 30)` → "Name: Alice, Age: 30" -/// -/// Format value for println (strings without quotes) -/// Complexity: 2 (within Toyota Way limits) -fn format_value_for_println(value: &Value) -> String { - match value { - Value::String(s) => s.to_string(), - other => format!("{other}"), - } +/// Print values to stdout with newline. FMTSPEC-1: the first value is a +/// Rust format string when more values follow it (`println("{:>5}", 7)`). +fn eval_println(args: &[Value]) -> Result { + eval_builtin_format(FormatSink::Println, args) } -/// Format string with interpolation -/// Complexity: 2 (within Toyota Way limits) -fn format_with_interpolation(fmt_str: &str, args: &[Value]) -> String { - let mut result = fmt_str.to_string(); - for arg in args { - if let Some(pos) = result.find("{}") { - result.replace_range(pos..pos + 2, &format_value_for_println(arg)); - } - } - result +/// Print values to stdout without newline, formatted like [`eval_println`]. +fn eval_print(args: &[Value]) -> Result { + eval_builtin_format(FormatSink::Print, args) } -/// Join values with spaces -/// Complexity: 1 (within Toyota Way limits) +/// Text of one println argument (strings without quotes); unit-test API. +#[cfg(test)] +fn format_value_for_println(value: &Value) -> String { + crate::runtime::value_format::format_value_display(value) +} + +/// Values joined by spaces, as `println(a, b)` prints them; unit-test API. +#[cfg(test)] fn join_values(args: &[Value]) -> String { args.iter() .map(format_value_for_println) @@ -318,58 +312,11 @@ fn join_values(args: &[Value]) -> String { .join(" ") } -/// Format println output -/// Complexity: 3 (within Toyota Way limits, reduced from 7) +/// The line `println(args)` prints (an error prints its message); unit-test API. +#[cfg(test)] fn format_println_output(args: &[Value]) -> String { - if args.is_empty() { - "\n".to_string() - } else if let Value::String(fmt_str) = &args[0] { - if fmt_str.contains("{}") { - format!("{}\n", format_with_interpolation(fmt_str, &args[1..])) - } else { - format!("{}\n", join_values(args)) - } - } else { - format!("{}\n", join_values(args)) - } -} - -/// Print values to stdout with newline -/// Complexity: 2 (within Toyota Way limits, reduced from 7) -fn eval_println(args: &[Value]) -> Result { - let output = format_println_output(args); - - // Write to output buffer (for notebook capture) - if let Ok(mut buf) = crate::runtime::builtins::OUTPUT_BUFFER.lock() { - buf.push_str(&output); - } - - // Also write to stdout for local REPL use - print!("{output}"); - let _ = std::io::stdout().flush(); - - Ok(Value::Nil) -} - -/// Print values to stdout without newline -/// -fn eval_print(args: &[Value]) -> Result { - let output = args - .iter() - .map(|v| format!("{v}")) - .collect::>() - .join(" "); - - // Write to output buffer (for notebook capture) - if let Ok(mut buf) = crate::runtime::builtins::OUTPUT_BUFFER.lock() { - buf.push_str(&output); - } - - // Also write to stdout for local REPL use - print!("{output}"); - let _ = std::io::stdout().flush(); - - Ok(Value::Nil) + crate::runtime::fmt_spec::format_call(args, args.len() > 1, &|_| None) + .map_or_else(|e| e, |text| format!("{text}\n")) } /// Debug print with value inspection diff --git a/src/runtime/eval_builtin_fs.rs b/src/runtime/eval_builtin_fs.rs index 7269b8a78..6edc59f21 100644 --- a/src/runtime/eval_builtin_fs.rs +++ b/src/runtime/eval_builtin_fs.rs @@ -487,10 +487,7 @@ pub(crate) fn eval_compute_hash(args: &[Value]) -> Result Err(InterpreterError::RuntimeError( diff --git a/src/runtime/eval_builtin_prop_tests_part1.rs b/src/runtime/eval_builtin_prop_tests_part1.rs index 4610b42af..df2a3b875 100644 --- a/src/runtime/eval_builtin_prop_tests_part1.rs +++ b/src/runtime/eval_builtin_prop_tests_part1.rs @@ -1848,7 +1848,7 @@ fn test_format_value_for_println_integer() { #[test] fn test_format_with_interpolation_multiple() { - let result = format_with_interpolation( + let result = crate::runtime::value_format::format_string_with_values( "{} + {} = {}", &[Value::Integer(1), Value::Integer(2), Value::Integer(3)], ); diff --git a/src/runtime/eval_builtin_tests/tests/eval_builtin_tests_part1.rs b/src/runtime/eval_builtin_tests/tests/eval_builtin_tests_part1.rs index 82b7b68a1..a33facaa8 100644 --- a/src/runtime/eval_builtin_tests/tests/eval_builtin_tests_part1.rs +++ b/src/runtime/eval_builtin_tests/tests/eval_builtin_tests_part1.rs @@ -330,14 +330,11 @@ fn test_hash_deterministic() { let result1 = compute_hash_of("hello"); let result2 = compute_hash_of("hello"); assert_eq!(result1, result2); - assert_eq!( - result1, - "\"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824\"" - ); + assert_eq!(result1, "\"5d41402abc4b2a76b9719d911017c592\""); } /// METHODS-1: `hash(x)` is neither an interpreter nor a transpiler builtin; -/// `compute_hash(path)` (SHA-256 of a file's bytes) is the existing equivalent. +/// `compute_hash(path)` (MD5 of a file's bytes, STDLIB-005) is the existing equivalent. pub(super) fn compute_hash_of(content: &str) -> String { let file = tempfile::NamedTempFile::new().expect("create temp file"); std::fs::write(file.path(), content).expect("write temp file"); diff --git a/src/runtime/eval_builtin_tests/tests/eval_builtin_tests_part2.rs b/src/runtime/eval_builtin_tests/tests/eval_builtin_tests_part2.rs index 148d9dec8..f52766376 100644 --- a/src/runtime/eval_builtin_tests/tests/eval_builtin_tests_part2.rs +++ b/src/runtime/eval_builtin_tests/tests/eval_builtin_tests_part2.rs @@ -662,13 +662,34 @@ fn test_fs_metadata_temp_dir() { } } +/// RHLGA-1: the walk tests used to walk `env_temp_dir()` — the whole shared +/// `/tmp` (millions of entries on a busy host, mutated concurrently by other +/// tests), so their time and memory scaled with the host, not the code. They +/// walk a private fixture instead: `root/a.txt`, `root/sub/b.txt`, i.e. four +/// entries at depths 0, 1, 1, 2. +fn walk_fixture() -> tempfile::TempDir { + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write(dir.path().join("a.txt"), "a").expect("write a.txt"); + std::fs::create_dir(dir.path().join("sub")).expect("mkdir sub"); + std::fs::write(dir.path().join("sub").join("b.txt"), "bb").expect("write b.txt"); + dir +} + +/// Evaluate `code` with `ROOT` replaced by the fixture path as a string literal. +fn eval_on_fixture(code: &str) -> String { + let dir = walk_fixture(); + let root = format!("{:?}", dir.path().display().to_string()); + eval(&code.replace("ROOT", &root)) +} + #[test] fn test_walk_temp_dir() { - let result = try_eval("walk(env_temp_dir())"); - if let Some(r) = result { - // Should return array of FileEntry objects - assert!(r.contains("[") || r.contains("path")); - } + assert_eq!(eval_on_fixture("walk(ROOT).len()"), "4"); + let names = eval_on_fixture("walk(ROOT).map(|e| e.name)"); + assert!( + names.contains("a.txt") && names.contains("b.txt"), + "{names}" + ); } #[test] @@ -783,22 +804,16 @@ fn test_sleep_float() { // === Hash Function === #[test] fn test_compute_hash_of_file_containing_hello() { - // METHODS-1: `hash(x)` does not exist; SHA-256 via `compute_hash` is deterministic + // METHODS-1: `hash(x)` does not exist; MD5 via `compute_hash` is deterministic let result = super::part1::compute_hash_of("hello"); - assert_eq!( - result, - "\"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824\"" - ); + assert_eq!(result, "\"5d41402abc4b2a76b9719d911017c592\""); } #[test] fn test_compute_hash_of_file_containing_42() { // METHODS-1: `hash(x)` does not exist; hash the decimal text of 42 instead let result = super::part1::compute_hash_of("42"); - assert_eq!( - result, - "\"73475cb40a568e8da8a045ced110137e159f890ac4da883b6b17dc651b3a8049\"" - ); + assert_eq!(result, "\"a1d0c6e83f027327d8461063f4ac58a6\""); } // === Additional String Methods via REPL === @@ -920,27 +935,34 @@ fn test_append_file() { // === Walk With Options === #[test] fn test_walk_with_options_max_depth() { - let result = try_eval("walk_with_options(env_temp_dir(), {max_depth: 1})"); - if let Some(r) = result { - assert!(r.contains("[") || r.contains("path")); - } + assert_eq!( + eval_on_fixture("walk_with_options(ROOT, {max_depth: 1}).len()"), + "3" + ); } #[test] fn test_walk_with_options_min_depth() { - let result = try_eval("walk_with_options(env_temp_dir(), {min_depth: 0})"); - if let Some(r) = result { - assert!(r.contains("[") || r.contains("path")); - } + assert_eq!( + eval_on_fixture("walk_with_options(ROOT, {min_depth: 0}).len()"), + "4" + ); + assert_eq!( + eval_on_fixture("walk_with_options(ROOT, {min_depth: 1}).len()"), + "3" + ); + assert_eq!( + eval_on_fixture("walk_with_options(ROOT, {min_depth: 2}).len()"), + "1" + ); } // === Walk Parallel === #[test] fn test_walk_parallel() { - let result = try_eval("walk_parallel(env_temp_dir())"); - if let Some(r) = result { - assert!(r.contains("[") || r.contains("path")); - } + assert_eq!(eval_on_fixture("walk_parallel(ROOT).len()"), "4"); + let sizes = eval_on_fixture("walk_parallel(ROOT).filter(|e| e.is_file).map(|e| e.size)"); + assert!(sizes.contains('1') && sizes.contains('2'), "{sizes}"); } // === Compute Hash === diff --git a/src/runtime/eval_expr.rs b/src/runtime/eval_expr.rs index 0582814bf..760945406 100644 --- a/src/runtime/eval_expr.rs +++ b/src/runtime/eval_expr.rs @@ -98,6 +98,7 @@ pub fn is_data_structure_expr(expr_kind: &ExprKind) -> bool { | ExprKind::Tuple(_) | ExprKind::Range { .. } | ExprKind::ArrayInit { .. } + | ExprKind::VecRepeat { .. } | ExprKind::DataFrame { .. } ) } diff --git a/src/runtime/eval_method.rs b/src/runtime/eval_method.rs index ac69edf99..8a2ccdb79 100644 --- a/src/runtime/eval_method.rs +++ b/src/runtime/eval_method.rs @@ -139,7 +139,7 @@ pub fn eval_integer_method( /// Evaluate generic methods available on all types /// /// # Complexity -/// Cyclomatic complexity: 3 (within Toyota Way limits) +/// Cyclomatic complexity: 5 (within Toyota Way limits) pub fn eval_generic_method( receiver: &Value, method: &str, @@ -147,6 +147,12 @@ pub fn eval_generic_method( ) -> Result { if method == "to_string" && args_empty { Ok(Value::from_string(receiver.to_string())) + } else if matches!(method, "clone" | "to_owned") + && args_empty + && matches!(receiver, Value::Tuple(_)) + { + // ARRCLONE-1: tuples are immutable values, so the clone is the tuple + Ok(receiver.clone()) } else { Err(InterpreterError::RuntimeError(format!( "Method '{}' not found for type {}", diff --git a/src/runtime/eval_method_dispatch.rs b/src/runtime/eval_method_dispatch.rs index bd7babf29..33d38adfa 100644 --- a/src/runtime/eval_method_dispatch.rs +++ b/src/runtime/eval_method_dispatch.rs @@ -7,6 +7,7 @@ use crate::frontend::ast::Expr; use crate::runtime::eval_array; +use crate::runtime::eval_option_methods; use crate::runtime::eval_string; use crate::runtime::interpreter::DataFrameColumn; use crate::runtime::{InterpreterError, Value}; @@ -92,7 +93,13 @@ where arg_values, ), Value::Object(obj) => eval_object_method(obj, base_method, arg_values), - _ => eval_generic_method(receiver, base_method, args_empty), + _ => eval_option_methods::eval_option_method( + receiver, + base_method, + arg_values, + &mut eval_function_call_value, + ) + .unwrap_or_else(|| eval_generic_method(receiver, base_method, args_empty)), } } @@ -229,17 +236,53 @@ fn try_dispatch_builtin( } } -/// METHODS-1: `keys`, `values`, `items`/`entries` on a plain object (no `__type`). +/// OPTMETHODS-1: `map.get(key)` on a plain object: the value, or nil if absent. +/// +/// # Complexity +/// Cyclomatic complexity: 3 +fn eval_plain_object_get( + obj: &std::collections::HashMap, + arg_values: &[Value], +) -> Result { + let [key] = arg_values else { + return Err(InterpreterError::RuntimeError(format!( + "Object method 'get' takes 1 argument, got {}", + arg_values.len() + ))); + }; + let key = match key { + Value::String(s) => s.to_string(), + other => other.to_string(), + }; + Ok(obj.get(&key).cloned().unwrap_or(Value::Nil)) +} + +/// METHODS-1: `keys`, `values`, `items`/`entries` on a plain object (no `__type`), +/// and `get` (OPTMETHODS-1). /// /// Entries come back in sorted key order, the order `Display` prints an object in. /// Returns `None` for any other method so the caller keeps its error path. /// /// # Complexity -/// Cyclomatic complexity: 5 +/// Cyclomatic complexity: 8 fn eval_plain_object_method( obj: &std::collections::HashMap, method: &str, arg_values: &[Value], +) -> Option> { + match method { + "get" => Some(eval_plain_object_get(obj, arg_values)), + // ARRCLONE-1: a copy of the map; field writes on it leave `obj` unchanged + "clone" if arg_values.is_empty() => Some(Ok(Value::Object(Arc::new(obj.clone())))), + _ => eval_plain_object_projection(obj, method, arg_values), + } +} + +/// `keys`, `values`, `items`/`entries` of a plain object, sorted by key +fn eval_plain_object_projection( + obj: &std::collections::HashMap, + method: &str, + arg_values: &[Value], ) -> Option> { let project: fn(&String, &Value) -> Value = match method { "keys" => |k, _| Value::from_string(k.clone()), diff --git a/src/runtime/eval_option_methods.rs b/src/runtime/eval_option_methods.rs new file mode 100644 index 000000000..6b92b5f25 --- /dev/null +++ b/src/runtime/eval_option_methods.rs @@ -0,0 +1,399 @@ +//! OPTMETHODS-1: `Option`/`Result` methods in the interpreter. +//! +//! The interpreter keeps nil-for-absent library results (`pop`, `first`, +//! `get` return the element or nil), so these methods accept both forms: +//! `Some(v)`/`Ok(v)` and a plain non-nil value are present; `None`, `Err(e)` +//! and nil are absent. `v.pop().unwrap()` therefore yields the element, as +//! the transpiled Rust does. +//! +//! A plain value only reaches these methods after its own type has reported +//! the method as unknown (see [`is_unknown_method_error`]), so an existing +//! string/array/integer/object method is never shadowed. + +use crate::runtime::{InterpreterError, Value}; + +/// Which constructor a present value is wrapped in when a method rebuilds it. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Family { + Option, + Result, + Plain, +} + +/// A receiver seen as an `Option`/`Result`. +enum Shape { + /// `Some(v)`, `Ok(v)` or a plain non-nil value. + Present(Family, Value), + /// `None` or nil (payload `Nil`), or `Err(e)` (payload `e`). + Absent(Family, Value), +} + +/// Result of an `Option` method dispatch; `None` means "not an `Option` method". +pub type OptionMethodResult = Option>; + +/// True if `receiver` is `Some`/`None`/`Ok`/`Err` or nil: these dispatch here +/// before any other method table. +/// +/// # Complexity +/// Cyclomatic complexity: 2 +pub fn is_option_receiver(receiver: &Value) -> bool { + matches!(receiver, Value::Nil) || variant_payload(receiver).is_some() +} + +/// True if `err` is a type's "no such method" error for `method`. +/// +/// # Complexity +/// Cyclomatic complexity: 4 +pub fn is_unknown_method_error(err: &InterpreterError, method: &str) -> bool { + let InterpreterError::RuntimeError(msg) = err else { + return false; + }; + let names_method = + msg.ends_with(&format!(": {method}")) || msg.contains(&format!("'{method}'")); + names_method && (msg.contains("Unknown") || msg.contains("not found")) +} + +/// The variant name and payload of a builtin `Some`/`None`/`Ok`/`Err` value. +/// The interpreter builds those with enum name `Option`/`Result`; an empty +/// name is accepted too. A user enum's own `Some`/`None`/`Ok`/`Err` variant +/// (`enum Maybe { Some(i32), None }`) is not one: its methods dispatch normally. +/// +/// # Complexity +/// Cyclomatic complexity: 4 +fn variant_payload(receiver: &Value) -> Option<(&str, Option<&Value>)> { + let Value::EnumVariant { + enum_name, + variant_name, + data, + } = receiver + else { + return None; + }; + if !matches!(enum_name.as_str(), "" | "Option" | "Result") { + return None; + } + let payload = data.as_ref().and_then(|d| d.first()); + match variant_name.as_str() { + "Some" | "None" | "Ok" | "Err" => Some((variant_name.as_str(), payload)), + _ => None, + } +} + +/// # Complexity +/// Cyclomatic complexity: 7 +fn classify(receiver: &Value) -> Shape { + let payload = |p: Option<&Value>| p.cloned().unwrap_or(Value::Nil); + match variant_payload(receiver) { + Some(("Some", p)) => Shape::Present(Family::Option, payload(p)), + Some(("Ok", p)) => Shape::Present(Family::Result, payload(p)), + Some(("Err", p)) => Shape::Absent(Family::Result, payload(p)), + Some(_) => Shape::Absent(Family::Option, Value::Nil), + None if matches!(receiver, Value::Nil) => Shape::Absent(Family::Plain, Value::Nil), + None => Shape::Present(Family::Plain, receiver.clone()), + } +} + +fn variant(enum_name: &str, variant_name: &str, data: Option) -> Value { + Value::EnumVariant { + enum_name: enum_name.to_string(), + variant_name: variant_name.to_string(), + data: data.map(|v| vec![v]), + } +} + +fn some(v: Value) -> Value { + variant("Option", "Some", Some(v)) +} + +fn none() -> Value { + variant("Option", "None", None) +} + +/// Rebuild a present value in its family's constructor. +fn wrap(family: Family, v: Value) -> Value { + match family { + Family::Option => some(v), + Family::Result => variant("Result", "Ok", Some(v)), + Family::Plain => v, + } +} + +/// The absent value of a family that has no payload to keep. +fn empty(family: Family) -> Value { + match family { + Family::Plain => Value::Nil, + _ => none(), + } +} + +/// How an absent receiver is named in error messages. +fn describe_absent(family: Family, payload: &Value) -> String { + match family { + Family::Option => "None".to_string(), + Family::Result => format!("Err({payload})"), + Family::Plain => "nil".to_string(), + } +} + +fn arg(method: &str, args: &[Value], count: usize) -> Result<(), InterpreterError> { + if args.len() == count { + return Ok(()); + } + Err(InterpreterError::RuntimeError(format!( + "{method}() takes {count} argument(s), got {}", + args.len() + ))) +} + +/// Evaluate an `Option`/`Result` method on `receiver`, or `None` if `method` +/// is not one of them. `call` invokes a closure argument. +/// +/// # Complexity +/// Cyclomatic complexity: 3 +pub fn eval_option_method( + receiver: &Value, + method: &str, + args: &[Value], + call: &mut F, +) -> OptionMethodResult +where + F: FnMut(&Value, &[Value]) -> Result, +{ + let shape = classify(receiver); + if let Some(result) = eval_query_method(&shape, method, args) { + return Some(result); + } + eval_combinator_method(receiver, shape, method, args, call) +} + +/// Predicates and extractors that never call a closure. +/// +/// # Complexity +/// Cyclomatic complexity: 9 +fn eval_query_method(shape: &Shape, method: &str, args: &[Value]) -> OptionMethodResult { + let present = matches!(shape, Shape::Present(..)); + let result = match method { + "is_some" | "is_ok" => arg(method, args, 0).map(|()| Value::Bool(present)), + "is_none" | "is_err" => arg(method, args, 0).map(|()| Value::Bool(!present)), + "unwrap" => arg(method, args, 0).and_then(|()| unwrap(shape, method, None)), + "expect" => arg(method, args, 1).and_then(|()| unwrap(shape, method, Some(&args[0]))), + "unwrap_or_default" => arg(method, args, 0).and_then(|()| unwrap_or_default(shape)), + "unwrap_or" => arg(method, args, 1).map(|()| present_or(shape, &args[0])), + "ok" => arg(method, args, 0).map(|()| ok(shape)), + "ok_or" => arg(method, args, 1).map(|()| ok_or(shape, &args[0])), + _ => return None, + }; + Some(result) +} + +/// `unwrap`/`expect`: the present value, or an error naming the absent one. +fn unwrap(shape: &Shape, method: &str, msg: Option<&Value>) -> Result { + match (shape, msg) { + (Shape::Present(_, v), _) => Ok(v.clone()), + (Shape::Absent(family, p), None) => Err(InterpreterError::RuntimeError(format!( + "called `{method}()` on {}", + describe_absent(*family, p) + ))), + (Shape::Absent(family, p), Some(msg)) => Err(InterpreterError::RuntimeError(format!( + "{}: {}", + display_text(msg), + describe_absent(*family, p) + ))), + } +} + +fn display_text(v: &Value) -> String { + match v { + Value::String(s) => s.to_string(), + other => other.to_string(), + } +} + +/// The default of an absent value's type is not known at runtime, so an +/// absent receiver is an error rather than a guessed default. +fn unwrap_or_default(shape: &Shape) -> Result { + match shape { + Shape::Present(_, v) => Ok(v.clone()), + Shape::Absent(family, p) => Err(InterpreterError::RuntimeError(format!( + "called `unwrap_or_default()` on {}: the default value's type is unknown at runtime", + describe_absent(*family, p) + ))), + } +} + +fn present_or(shape: &Shape, fallback: &Value) -> Value { + match shape { + Shape::Present(_, v) => v.clone(), + Shape::Absent(..) => fallback.clone(), + } +} + +fn ok(shape: &Shape) -> Value { + match shape { + Shape::Present(_, v) => some(v.clone()), + Shape::Absent(..) => none(), + } +} + +fn ok_or(shape: &Shape, err: &Value) -> Value { + match shape { + Shape::Present(_, v) => variant("Result", "Ok", Some(v.clone())), + Shape::Absent(..) => variant("Result", "Err", Some(err.clone())), + } +} + +/// Methods that take a closure or return the receiver itself. +/// +/// # Complexity +/// Cyclomatic complexity: 8 +fn eval_combinator_method( + receiver: &Value, + shape: Shape, + method: &str, + args: &[Value], + call: &mut F, +) -> OptionMethodResult +where + F: FnMut(&Value, &[Value]) -> Result, +{ + let result = match method { + "or" => arg(method, args, 1).map(|()| or(receiver, &shape, &args[0])), + "unwrap_or_else" => { + arg(method, args, 1).and_then(|()| unwrap_or_else(shape, &args[0], call)) + } + "map" => arg(method, args, 1).and_then(|()| map(receiver, shape, &args[0], call)), + "and_then" => arg(method, args, 1).and_then(|()| and_then(receiver, shape, &args[0], call)), + "filter" => arg(method, args, 1).and_then(|()| filter(receiver, shape, &args[0], call)), + _ => return None, + }; + Some(result) +} + +fn or(receiver: &Value, shape: &Shape, fallback: &Value) -> Value { + match shape { + Shape::Present(..) => receiver.clone(), + Shape::Absent(..) => fallback.clone(), + } +} + +/// `Err(e).unwrap_or_else(f)` passes `e` to `f`; `None`/nil pass nothing. +fn unwrap_or_else(shape: Shape, f: &Value, call: &mut F) -> Result +where + F: FnMut(&Value, &[Value]) -> Result, +{ + match shape { + Shape::Present(_, v) => Ok(v), + Shape::Absent(Family::Result, e) => call(f, &[e]), + Shape::Absent(..) => call(f, &[]), + } +} + +fn map( + receiver: &Value, + shape: Shape, + f: &Value, + call: &mut F, +) -> Result +where + F: FnMut(&Value, &[Value]) -> Result, +{ + match shape { + Shape::Present(family, v) => Ok(wrap(family, call(f, &[v])?)), + Shape::Absent(..) => Ok(receiver.clone()), + } +} + +fn and_then( + receiver: &Value, + shape: Shape, + f: &Value, + call: &mut F, +) -> Result +where + F: FnMut(&Value, &[Value]) -> Result, +{ + match shape { + Shape::Present(_, v) => call(f, &[v]), + Shape::Absent(..) => Ok(receiver.clone()), + } +} + +fn filter( + receiver: &Value, + shape: Shape, + pred: &Value, + call: &mut F, +) -> Result +where + F: FnMut(&Value, &[Value]) -> Result, +{ + match shape { + Shape::Present(family, v) => { + let keep = call(pred, &[v])?.is_truthy(); + Ok(if keep { + receiver.clone() + } else { + empty(family) + }) + } + Shape::Absent(..) => Ok(receiver.clone()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn no_call(_: &Value, _: &[Value]) -> Result { + Err(InterpreterError::RuntimeError("no closure".to_string())) + } + + fn eval(receiver: &Value, method: &str, args: &[Value]) -> Result { + eval_option_method(receiver, method, args, &mut no_call).expect("an option method") + } + + #[test] + fn test_optmethods_1_unit_present_and_absent_forms() { + let some3 = some(Value::Integer(3)); + assert_eq!(eval(&some3, "unwrap", &[]).unwrap(), Value::Integer(3)); + assert_eq!( + eval(&Value::Integer(4), "unwrap", &[]).unwrap(), + Value::Integer(4) + ); + assert_eq!(eval(&none(), "is_none", &[]).unwrap(), Value::Bool(true)); + assert_eq!( + eval(&Value::Nil, "is_some", &[]).unwrap(), + Value::Bool(false) + ); + let fallback = [Value::Integer(5)]; + assert_eq!( + eval(&Value::Nil, "unwrap_or", &fallback).unwrap(), + Value::Integer(5) + ); + } + + #[test] + fn test_optmethods_1_unit_absent_unwrap_errors_name_the_value() { + let err = variant( + "Result", + "Err", + Some(Value::from_string("boom".to_string())), + ); + let msg = eval(&err, "unwrap", &[]).unwrap_err().to_string(); + assert!(msg.contains("boom"), "{msg}"); + let msg = eval(&Value::Nil, "unwrap", &[]).unwrap_err().to_string(); + assert!(msg.contains("nil"), "{msg}"); + assert!(eval(&none(), "unwrap_or_default", &[]).is_err()); + } + + #[test] + fn test_optmethods_1_unit_non_option_method_and_receiver() { + assert!(eval_option_method(&none(), "len", &[], &mut no_call).is_none()); + assert!(is_option_receiver(&Value::Nil)); + assert!(!is_option_receiver(&Value::Integer(1))); + let unknown = InterpreterError::RuntimeError("Unknown integer method: unwrap".to_string()); + assert!(is_unknown_method_error(&unknown, "unwrap")); + let other = InterpreterError::RuntimeError("Unknown integer method: map_x".to_string()); + assert!(!is_unknown_method_error(&other, "map")); + } +} diff --git a/src/runtime/eval_string_interpolation.rs b/src/runtime/eval_string_interpolation.rs index f22965141..7ffe52436 100644 --- a/src/runtime/eval_string_interpolation.rs +++ b/src/runtime/eval_string_interpolation.rs @@ -99,27 +99,31 @@ pub fn format_value_with_spec(value: &Value, format_spec: &str) -> String { } /// Format a value for string interpolation (no extra quotes for strings) -/// -/// # Complexity -/// Cyclomatic complexity: 8 (within Toyota Way limits) pub fn format_value_for_interpolation(value: &Value) -> String { + match value { + // FLOATDISP-1: `f"{x}"` transpiles to `{}`, Rust's f64 Display. + Value::Float(f) => crate::runtime::fmt_spec::display_float(*f), + _ => interpolation_text(value), + } +} + +/// Interpolation text of a non-float value; compound elements keep the +/// value's own text. +fn interpolation_text(value: &Value) -> String { match value { Value::String(s) => s.to_string(), // No quotes for interpolation Value::Array(arr) => { - let elements: Vec = arr.iter().map(format_value_for_interpolation).collect(); + let elements: Vec = arr.iter().map(interpolation_text).collect(); format!("[{}]", elements.join(", ")) } Value::Tuple(elements) => { - let formatted: Vec = elements - .iter() - .map(format_value_for_interpolation) - .collect(); + let formatted: Vec = elements.iter().map(interpolation_text).collect(); format!("({})", formatted.join(", ")) } Value::Object(map) => { let mut entries = Vec::new(); for (k, v) in map.iter() { - entries.push(format!("{}: {}", k, format_value_for_interpolation(v))); + entries.push(format!("{}: {}", k, interpolation_text(v))); } format!("{{{}}}", entries.join(", ")) } diff --git a/src/runtime/eval_string_methods.rs b/src/runtime/eval_string_methods.rs index 5a40bf0ae..e098750c0 100644 --- a/src/runtime/eval_string_methods.rs +++ b/src/runtime/eval_string_methods.rs @@ -38,7 +38,8 @@ fn eval_zero_arg_string_method(s: &Arc, method: &str) -> Result Ok(Value::Integer(s.len() as i64)), "to_upper" | "to_uppercase" | "upper" => Ok(Value::from_string(s.to_uppercase())), "to_lower" | "to_lowercase" | "lower" => Ok(Value::from_string(s.to_lowercase())), - "to_string" => Ok(Value::from_string(s.to_string())), + // ARRCLONE-1: `clone`/`to_owned` copy the string, like `to_string` + "to_string" | "clone" | "to_owned" => Ok(Value::from_string(s.to_string())), "is_empty" => Ok(Value::Bool(s.is_empty())), "is_numeric" => Ok(Value::Bool(s.chars().all(char::is_numeric))), "is_alphabetic" => Ok(Value::Bool(s.chars().all(char::is_alphabetic))), @@ -48,10 +49,17 @@ fn eval_zero_arg_string_method(s: &Arc, method: &str) -> Result Ok(Value::from_string(s.trim_end().to_string())), "chars" => eval_string_chars(s), "lines" => eval_string_lines(s), + // STRRECV-1: whitespace-separated words, as `str::split_whitespace` + "split_whitespace" => Ok(Value::from_array( + s.split_whitespace() + .map(|w| Value::from_string(w.to_string())) + .collect(), + )), "parse" | "to_int" | "to_integer" => eval_string_parse(s), "timestamp" => eval_string_timestamp(s), "to_rfc3339" => Ok(Value::from_string(s.to_string())), - "as_bytes" => eval_string_as_bytes(s), + // COUNTITER-1: `bytes()` yields the same UTF-8 bytes as `as_bytes()` + "as_bytes" | "bytes" => eval_string_as_bytes(s), _ => Err(InterpreterError::RuntimeError(format!( "Unknown zero-argument string method: {method}" ))), diff --git a/src/runtime/fmt_spec.rs b/src/runtime/fmt_spec.rs new file mode 100644 index 000000000..a9b2deac4 --- /dev/null +++ b/src/runtime/fmt_spec.rs @@ -0,0 +1,678 @@ +//! FMTSPEC-1: Rust `std::fmt` format strings for the interpreter. +//! +//! One parser for the format-spec language, shared by `println`/`print`/ +//! `eprintln`/`eprint`/`format` (function and macro forms) and by the +//! `{expr:spec}` f-string interpolation. +//! +//! Supported: `{}`, `{0}`, `{name}`, `{{`/`}}`, fill/align `<^>`, `+`, `#`, +//! `0`, width, `.precision`, and the traits `?`, `x`, `X`, `b`, `o`, `e`, `E`. +//! Width or precision taken from an argument (`{:1$}`, `{:.*}`, `{:w$}`) is +//! an error naming the spec, never silently wrong output. + +use crate::runtime::value_format::{format_value_display, rust_debug}; +use crate::runtime::Value; +use std::collections::HashMap; + +/// Which argument a field refers to. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ArgRef { + /// `{}`: the next implicit positional argument. + Next, + /// `{0}`: an explicit positional argument. + Index(usize), + /// `{name}`: a named argument or a variable in scope. + Name(String), +} + +/// Alignment inside the field width. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Align { + Left, + Center, + Right, +} + +/// The formatting trait a field selects. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Kind { + Display, + Debug, + LowerHex, + UpperHex, + Binary, + Octal, + LowerExp, + UpperExp, +} + +/// A parsed `:spec`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Spec { + pub fill: char, + pub align: Option, + pub plus: bool, + pub alternate: bool, + pub zero: bool, + pub width: Option, + pub precision: Option, + pub kind: Kind, +} + +impl Default for Spec { + fn default() -> Self { + Self { + fill: ' ', + align: None, + plus: false, + alternate: false, + zero: false, + width: None, + precision: None, + kind: Kind::Display, + } + } +} + +/// One piece of a parsed format string. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Piece { + Text(String), + Field(ArgRef, Spec), +} + +type Chars<'a> = std::iter::Peekable>; + +/// Parse a format string into text and fields. +pub fn parse(fmt: &str) -> Result, String> { + let mut pieces = Vec::new(); + let mut text = String::new(); + let mut chars = fmt.chars().peekable(); + while let Some(c) = chars.next() { + parse_char(c, &mut chars, &mut pieces, &mut text)?; + } + flush_text(&mut pieces, &mut text); + Ok(pieces) +} + +/// One character of a format string: a `{{`/`}}` escape, a field, or text. +fn parse_char( + c: char, + chars: &mut Chars<'_>, + pieces: &mut Vec, + text: &mut String, +) -> Result<(), String> { + if matches!(c, '{' | '}') && chars.peek() == Some(&c) { + chars.next(); + text.push(c); + return Ok(()); + } + match c { + '{' => { + flush_text(pieces, text); + pieces.push(parse_field(&take_field(chars)?)?); + Ok(()) + } + '}' => Err("unmatched `}` in format string (use `}}` for a literal brace)".into()), + _ => { + text.push(c); + Ok(()) + } + } +} + +fn flush_text(pieces: &mut Vec, text: &mut String) { + if !text.is_empty() { + pieces.push(Piece::Text(std::mem::take(text))); + } +} + +fn take_field(chars: &mut Chars<'_>) -> Result { + let mut body = String::new(); + for c in chars.by_ref() { + match c { + '}' => return Ok(body), + '{' => return Err(format!("invalid format field `{{{body}{{`")), + _ => body.push(c), + } + } + Err(format!( + "unterminated format field `{{{body}` (use `{{{{` for a literal brace)" + )) +} + +fn parse_field(body: &str) -> Result { + let (arg, spec) = match body.split_once(':') { + Some((arg, spec)) => (arg, Some(spec)), + None => (body, None), + }; + let spec = match spec { + Some(text) => parse_spec(text)?, + None => Spec::default(), + }; + Ok(Piece::Field(parse_arg(arg.trim())?, spec)) +} + +fn parse_arg(arg: &str) -> Result { + if arg.is_empty() { + return Ok(ArgRef::Next); + } + if let Ok(index) = arg.parse::() { + return Ok(ArgRef::Index(index)); + } + if is_identifier(arg) { + return Ok(ArgRef::Name(arg.to_string())); + } + Err(format!("invalid format argument `{arg}`")) +} + +fn is_identifier(s: &str) -> bool { + let mut chars = s.chars(); + let first_ok = chars.next().is_some_and(|c| c.is_alphabetic() || c == '_'); + first_ok && chars.all(|c| c.is_alphanumeric() || c == '_') +} + +/// Parse the text after `:` in a field. +pub fn parse_spec(text: &str) -> Result { + parse_spec_chars(&text.chars().collect::>()) + .map_err(|e| format!("{e} in format spec `{{:{text}}}`")) +} + +fn parse_spec_chars(chars: &[char]) -> Result { + let mut spec = Spec::default(); + let mut i = parse_fill_align(chars, &mut spec); + i = parse_flags(chars, i, &mut spec); + let (width, mut i) = parse_count(chars, i)?; + spec.width = width; + if chars.get(i) == Some(&'.') { + let (precision, next) = parse_precision(chars, i + 1)?; + spec.precision = Some(precision); + i = next; + } + spec.kind = parse_kind(&chars[i..].iter().collect::())?; + Ok(spec) +} + +fn align_of(c: char) -> Option { + match c { + '<' => Some(Align::Left), + '^' => Some(Align::Center), + '>' => Some(Align::Right), + _ => None, + } +} + +fn parse_fill_align(chars: &[char], spec: &mut Spec) -> usize { + if let Some(align) = chars.get(1).copied().and_then(align_of) { + spec.fill = chars[0]; + spec.align = Some(align); + return 2; + } + if let Some(align) = chars.first().copied().and_then(align_of) { + spec.align = Some(align); + return 1; + } + 0 +} + +fn parse_flags(chars: &[char], mut i: usize, spec: &mut Spec) -> usize { + if matches!(chars.get(i), Some('+' | '-')) { + spec.plus = chars[i] == '+'; + i += 1; + } + if chars.get(i) == Some(&'#') { + spec.alternate = true; + i += 1; + } + if chars.get(i) == Some(&'0') { + spec.zero = true; + i += 1; + } + i +} + +/// Digits at `i`; a count taken from an argument (`1$`) is refused. +fn parse_count(chars: &[char], i: usize) -> Result<(Option, usize), String> { + let end = i + chars[i..].iter().take_while(|c| c.is_ascii_digit()).count(); + if chars.get(end) == Some(&'$') { + return Err("width/precision taken from an argument (`N$`) is not supported".into()); + } + let digits: String = chars[i..end].iter().collect(); + Ok((digits.parse().ok(), end)) +} + +fn parse_precision(chars: &[char], i: usize) -> Result<(usize, usize), String> { + if chars.get(i) == Some(&'*') { + return Err("precision taken from an argument (`.*`) is not supported".into()); + } + match parse_count(chars, i)? { + (Some(precision), next) => Ok((precision, next)), + (None, _) => Err("missing precision after `.`".into()), + } +} + +fn parse_kind(text: &str) -> Result { + match text { + "" => Ok(Kind::Display), + "?" => Ok(Kind::Debug), + "x" => Ok(Kind::LowerHex), + "X" => Ok(Kind::UpperHex), + "b" => Ok(Kind::Binary), + "o" => Ok(Kind::Octal), + "e" => Ok(Kind::LowerExp), + "E" => Ok(Kind::UpperExp), + other => Err(format!("unsupported format trait `{other}`")), + } +} + +/// Render parsed pieces. `named` resolves `{name}` fields. +pub fn render( + pieces: &[Piece], + args: &[Value], + named: &dyn Fn(&str) -> Option, +) -> Result { + let mut out = String::new(); + let mut next = 0; + let mut used = vec![false; args.len()]; + for piece in pieces { + match piece { + Piece::Text(text) => out.push_str(text), + Piece::Field(arg, spec) => { + mark_used(arg, next, &mut used); + let value = resolve(arg, args, &mut next, named)?; + out.push_str(&format_value(&value, spec)?); + } + } + } + check_all_used(&used)?; + Ok(out) +} + +/// FMTEXTRA-1: record the positional argument `arg` refers to (`{}` takes +/// the next one, `{0}` names one); a named field uses none. +fn mark_used(arg: &ArgRef, next: usize, used: &mut [bool]) { + let slot = match arg { + ArgRef::Next => next, + ArgRef::Index(i) => *i, + ArgRef::Name(_) => return, + }; + if let Some(flag) = used.get_mut(slot) { + *flag = true; + } +} + +/// FMTEXTRA-1: Rust rejects a positional argument the template never uses. +fn check_all_used(used: &[bool]) -> Result<(), String> { + match used.iter().position(|u| !u) { + Some(i) => Err(format!( + "argument never used: positional argument {i} is not referenced by the format string" + )), + None => Ok(()), + } +} + +fn resolve( + arg: &ArgRef, + args: &[Value], + next: &mut usize, + named: &dyn Fn(&str) -> Option, +) -> Result { + match arg { + ArgRef::Next => { + *next += 1; + args.get(*next - 1).cloned().ok_or_else(|| { + format!( + "format string needs {} positional argument(s), {} given", + *next, + args.len() + ) + }) + } + ArgRef::Index(i) => args.get(*i).cloned().ok_or_else(|| { + format!( + "invalid reference to positional argument {i} ({} given)", + args.len() + ) + }), + ArgRef::Name(name) => { + named(name).ok_or_else(|| format!("there is no argument named `{name}`")) + } + } +} + +/// Parse and render `fmt` in one step. +pub fn format_str( + fmt: &str, + args: &[Value], + named: &dyn Fn(&str) -> Option, +) -> Result { + render(&parse(fmt)?, args, named) +} + +/// Text of an output call (`println(args…)`, `format(args…)`). +/// +/// With `template`, the first value is the format string. A template with no +/// fields and extra arguments keeps the historical ruchy form +/// (`println("a", 1)` prints `a 1`), as the transpiler does. A template with +/// fields must use every argument (FMTEXTRA-1). Without a template the values +/// are joined by spaces. +pub fn format_call( + values: &[Value], + template: bool, + named: &dyn Fn(&str) -> Option, +) -> Result { + let Some((Value::String(fmt), rest)) = values.split_first() else { + return Ok(join_display(values)); + }; + if !template { + return Ok(join_display(values)); + } + let pieces = parse(fmt)?; + let has_field = pieces.iter().any(|p| matches!(p, Piece::Field(..))); + if !has_field && !rest.is_empty() { + return Ok(join_display(values)); + } + render(&pieces, rest, named) +} + +fn join_display(values: &[Value]) -> String { + values.iter().map(bare_text).collect::>().join(" ") +} + +/// FLOATDISP-1: text of a bare `println(v)` argument. The transpiler emits +/// `{:?}` for a non-string argument, so a float keeps Rust's Debug text +/// (`3.0`, `1e21`). +fn bare_text(value: &Value) -> String { + match value { + Value::Float(_) => rust_debug(value), + _ => format_value_display(value), + } +} + +/// FLOATDISP-1: a float under `{}`, as Rust's `impl Display for f64` writes +/// it (`3`, `2.5`, `1000000000000000000000`, `NaN`, `inf`, `-0`). +#[must_use] +pub fn display_float(f: f64) -> String { + format!("{f}") +} + +/// Format one value under a spec. +pub fn format_value(value: &Value, spec: &Spec) -> Result { + if spec.kind == Kind::Debug && is_compound(value) { + return debug_compound(value, spec); + } + format_scalar(value, spec) +} + +fn is_compound(value: &Value) -> bool { + matches!( + value, + Value::Array(_) + | Value::Tuple(_) + | Value::Struct { .. } + | Value::Object(_) + | Value::ObjectMut(_) + | Value::EnumVariant { .. } + ) +} + +fn format_scalar(value: &Value, spec: &Spec) -> Result { + let (sign, body, numeric) = match value { + Value::Integer(i) => (int_sign(*i, spec), int_digits(*i, spec), true), + Value::Float(f) => (float_sign(*f, spec), float_digits(*f, spec)?, true), + // `impl Debug for str` writes the quoted text and ignores width. + Value::String(_) if spec.kind == Kind::Debug => return Ok(rust_debug(value)), + _ => (String::new(), text_body(value, spec)?, false), + }; + Ok(pad(&sign, &body, spec, numeric)) +} + +fn is_radix(kind: Kind) -> bool { + matches!( + kind, + Kind::LowerHex | Kind::UpperHex | Kind::Binary | Kind::Octal + ) +} + +fn int_sign(i: i64, spec: &Spec) -> String { + let radix = is_radix(spec.kind); + let sign = if i < 0 && !radix { + "-" + } else if spec.plus { + "+" + } else { + "" + }; + let prefix = match spec.kind { + Kind::LowerHex | Kind::UpperHex if spec.alternate => "0x", + Kind::Binary if spec.alternate => "0b", + Kind::Octal if spec.alternate => "0o", + _ => "", + }; + format!("{sign}{prefix}") +} + +fn int_digits(i: i64, spec: &Spec) -> String { + let magnitude = i.unsigned_abs(); + match (spec.kind, spec.precision) { + (Kind::LowerHex, _) => format!("{i:x}"), + (Kind::UpperHex, _) => format!("{i:X}"), + (Kind::Binary, _) => format!("{i:b}"), + (Kind::Octal, _) => format!("{i:o}"), + (Kind::LowerExp, Some(p)) => format!("{magnitude:.p$e}"), + (Kind::LowerExp, None) => format!("{magnitude:e}"), + (Kind::UpperExp, Some(p)) => format!("{magnitude:.p$E}"), + (Kind::UpperExp, None) => format!("{magnitude:E}"), + (Kind::Display | Kind::Debug, _) => magnitude.to_string(), + } +} + +fn float_sign(f: f64, spec: &Spec) -> String { + let sign = if f.is_sign_negative() && !f.is_nan() { + "-" + } else if spec.plus { + "+" + } else { + "" + }; + sign.to_string() +} + +fn float_digits(f: f64, spec: &Spec) -> Result { + let a = f.abs(); + Ok(match (spec.kind, spec.precision) { + (Kind::LowerExp, Some(p)) => format!("{a:.p$e}"), + (Kind::LowerExp, None) => format!("{a:e}"), + (Kind::UpperExp, Some(p)) => format!("{a:.p$E}"), + (Kind::UpperExp, None) => format!("{a:E}"), + (Kind::Display | Kind::Debug, Some(p)) => format!("{a:.p$}"), + (Kind::Display, None) => display_float(a), + (Kind::Debug, None) => rust_debug(&Value::Float(a)), + (kind, _) => { + return Err(format!( + "format trait {kind:?} needs an integer, got a float" + )) + } + }) +} + +fn text_body(value: &Value, spec: &Spec) -> Result { + match spec.kind { + // Rust's `pad` truncates str and bool; other Display impls ignore precision. + Kind::Display if matches!(value, Value::String(_) | Value::Bool(_)) => { + Ok(truncate(&format_value_display(value), spec.precision)) + } + Kind::Display => Ok(format_value_display(value)), + Kind::Debug => Ok(rust_debug(value)), + kind => Err(format!( + "format trait {kind:?} needs a number, got {}", + value.type_name() + )), + } +} + +fn truncate(text: &str, precision: Option) -> String { + match precision { + Some(p) => text.chars().take(p).collect(), + None => text.to_string(), + } +} + +/// Apply width, fill and alignment. `0` pads numbers after the sign. +fn pad(sign: &str, body: &str, spec: &Spec, numeric: bool) -> String { + let len = sign.chars().count() + body.chars().count(); + let gap = spec.width.unwrap_or(0).saturating_sub(len); + if spec.zero && numeric { + return format!("{sign}{}{body}", "0".repeat(gap)); + } + let default = if numeric { Align::Right } else { Align::Left }; + let (left, right) = match spec.align.unwrap_or(default) { + Align::Left => (0, gap), + Align::Right => (gap, 0), + Align::Center => (gap / 2, gap - gap / 2), + }; + let fill = |n: usize| std::iter::repeat_n(spec.fill, n).collect::(); + format!("{}{sign}{body}{}", fill(left), fill(right)) +} + +// ------------------------------------------------------------- compound `{:?}` + +struct Delims<'a> { + open: &'a str, + close: &'a str, + spaced: bool, + one_tuple: bool, +} + +const LIST: Delims<'static> = Delims { + open: "[", + close: "]", + spaced: false, + one_tuple: false, +}; + +/// Rust's derived/std Debug for a compound value: the spec reaches every +/// leaf (`{:5?}` pads each element) and `#` selects the pretty form. +fn debug_compound(value: &Value, spec: &Spec) -> Result { + match value { + Value::Array(items) => debug_seq(&LIST, items, spec), + Value::Tuple(items) => debug_seq(&tuple_delims(items.len()), items, spec), + Value::EnumVariant { + variant_name, data, .. + } => debug_variant(variant_name, data.as_deref().unwrap_or(&[]), spec), + Value::Struct { name, fields } => debug_map(Some(name), fields, spec), + Value::Object(map) => debug_object(map, spec), + Value::ObjectMut(cell) => { + let map = cell + .lock() + .map_err(|_| "object lock poisoned".to_string())?; + debug_object(&map, spec) + } + _ => format_scalar(value, spec), + } +} + +fn tuple_delims(len: usize) -> Delims<'static> { + Delims { + open: "(", + close: ")", + spaced: false, + one_tuple: len == 1, + } +} + +fn debug_seq(delims: &Delims<'_>, items: &[Value], spec: &Spec) -> Result { + let parts = items + .iter() + .map(|v| format_value(v, spec)) + .collect::, _>>()?; + Ok(wrap(delims, &parts, spec.alternate)) +} + +fn debug_variant(name: &str, data: &[Value], spec: &Spec) -> Result { + if data.is_empty() { + return Ok(name.to_string()); + } + let open = format!("{name}("); + let delims = Delims { + open: &open, + close: ")", + spaced: false, + one_tuple: false, + }; + debug_seq(&delims, data, spec) +} + +fn debug_object(map: &HashMap, spec: &Spec) -> Result { + match map.get("__class") { + Some(Value::String(class)) => debug_map(Some(class), map, spec), + _ => debug_map(None, map, spec), + } +} + +/// A struct (`Name { a: 1 }`) or, without a name, a map (`{"a": 1}`). +fn debug_map( + name: Option<&str>, + map: &HashMap, + spec: &Spec, +) -> Result { + let mut entries: Vec<_> = map.iter().filter(|(k, _)| !k.starts_with("__")).collect(); + entries.sort_by(|a, b| a.0.cmp(b.0)); + let parts = entries + .into_iter() + .map(|(k, v)| { + let key = if name.is_some() { + k.clone() + } else { + format!("{k:?}") + }; + format_value(v, spec).map(|v| format!("{key}: {v}")) + }) + .collect::, _>>()?; + match name { + Some(name) if parts.is_empty() => Ok(name.to_string()), + Some(name) => { + let open = format!("{name} {{"); + let delims = Delims { + open: &open, + close: "}", + spaced: true, + one_tuple: false, + }; + Ok(wrap(&delims, &parts, spec.alternate)) + } + None => { + let delims = Delims { + open: "{", + close: "}", + spaced: false, + one_tuple: false, + }; + Ok(wrap(&delims, &parts, spec.alternate)) + } + } +} + +fn wrap(delims: &Delims<'_>, parts: &[String], pretty: bool) -> String { + let (open, close) = (delims.open, delims.close); + if parts.is_empty() { + return format!("{open}{close}"); + } + if pretty { + let body: String = parts.iter().map(|p| format!("{},\n", indent(p))).collect(); + return format!("{open}\n{body}{close}"); + } + let space = if delims.spaced { " " } else { "" }; + let trail = if delims.one_tuple { "," } else { "" }; + format!("{open}{space}{}{trail}{space}{close}", parts.join(", ")) +} + +fn indent(text: &str) -> String { + text.lines() + .map(|line| format!(" {line}")) + .collect::>() + .join("\n") +} + +#[cfg(test)] +#[path = "fmt_spec_tests.rs"] +mod tests; diff --git a/src/runtime/fmt_spec_tests.rs b/src/runtime/fmt_spec_tests.rs new file mode 100644 index 000000000..ef6f1f61b --- /dev/null +++ b/src/runtime/fmt_spec_tests.rs @@ -0,0 +1,238 @@ +//! FMTSPEC-1 unit and property tests: every expected string is computed by +//! Rust's own `format!` in the test, so the oracle is std::fmt itself. + +use super::*; +use proptest::prelude::*; + +fn fmt(template: &str, args: &[Value]) -> Result { + format_str(template, args, &|_| None) +} + +fn s(text: &str) -> Value { + Value::from_string(text.to_string()) +} + +#[test] +fn test_fmtspec_1_brief_case() { + let got = fmt( + "[{:>5}] [{:.2}] [{0}]", + &[Value::Integer(7), Value::Float(3.14159)], + ); + assert_eq!(got.unwrap(), format!("[{:>5}] [{:.2}] [{0}]", 7, 3.14159)); +} + +#[test] +fn test_fmtspec_1_escapes() { + assert_eq!(fmt("{{}} {{{}}}", &[Value::Integer(1)]).unwrap(), "{} {1}"); +} + +#[test] +fn test_fmtspec_1_named_resolver() { + let got = format_str("{a}-{b:>3}", &[], &|n| match n { + "a" => Some(Value::Integer(1)), + "b" => Some(s("z")), + _ => None, + }); + assert_eq!(got.unwrap(), format!("{}-{:>3}", 1, "z")); + let missing = format_str("{nope}", &[], &|_| None).unwrap_err(); + assert!(missing.contains("nope"), "{missing}"); +} + +#[test] +fn test_fmtspec_1_radix_sign_alternate_zero() { + let args: Vec = [255, 255, 5, 8, 255, 5, 8, 5, 255, -1, 255] + .iter() + .map(|i| Value::Integer(*i)) + .collect(); + let got = fmt( + "{:x} {:X} {:b} {:o} {:#x} {:#b} {:#o} {:08b} {:#06x} {:x} {:+X}", + &args, + ); + let want = format!( + "{:x} {:X} {:b} {:o} {:#x} {:#b} {:#o} {:08b} {:#06x} {:x} {:+X}", + 255, 255, 5, 8, 255, 5, 8, 5, 255, -1i64, 255 + ); + assert_eq!(got.unwrap(), want); +} + +#[test] +fn test_fmtspec_1_exp_forms() { + let args = [ + Value::Float(1234.5), + Value::Float(0.00012), + Value::Integer(1234), + Value::Float(1234.5), + Value::Float(1.5), + ]; + let got = fmt("{:e} {:E} {:e} {:>10.2e} {:+e}", &args); + let want = format!( + "{:e} {:E} {:e} {:>10.2e} {:+e}", + 1234.5, 0.00012, 1234i64, 1234.5, 1.5 + ); + assert_eq!(got.unwrap(), want); +} + +#[test] +fn test_fmtspec_1_debug_forms() { + let arr = Value::from_array(vec![Value::Integer(1), Value::Integer(2)]); + let got = fmt( + "{:?}|{:5?}|{:>6?}|{:#?}|{:08.2?}", + &[arr.clone(), arr.clone(), s("ab"), arr, Value::Float(-1.5)], + ); + let v = vec![1, 2]; + let want = format!("{:?}|{:5?}|{:>6?}|{:#?}|{:08.2?}", v, v, "ab", v, -1.5); + assert_eq!(got.unwrap(), want); +} + +#[test] +fn test_fmtspec_1_pretty_tuple_and_variant() { + let tuple = Value::Tuple(vec![Value::Integer(1)].into()); + let some = Value::EnumVariant { + enum_name: "Option".to_string(), + variant_name: "Some".to_string(), + data: Some(vec![Value::Integer(3)]), + }; + let got = fmt("{:#?}|{:?}|{:#?}", &[tuple.clone(), tuple, some]); + assert_eq!( + got.unwrap(), + format!("{:#?}|{:?}|{:#?}", (1,), (1,), Some(3)) + ); +} + +#[test] +fn test_fmtspec_1_argument_counts_are_refused() { + for bad in ["{:1$}", "{:.*}", "{:w$}", "{:.1$}"] { + let err = fmt(bad, &[Value::Integer(1), Value::Integer(2)]).unwrap_err(); + assert!(err.contains("$") || err.contains(".*"), "{bad}: {err}"); + } +} + +#[test] +fn test_fmtspec_1_malformed_strings_are_errors() { + for bad in ["{", "}", "{:q}", "{0", "{a b}", "{:.}"] { + assert!(fmt(bad, &[Value::Integer(1)]).is_err(), "{bad}"); + } + assert!(fmt("{} {}", &[Value::Integer(1)]).is_err()); + assert!(fmt("{3}", &[Value::Integer(1)]).is_err()); + assert!(fmt("{:x}", &[Value::Float(1.0)]).is_err()); + assert!(fmt("{:x}", &[s("a")]).is_err()); +} + +#[test] +fn test_fmtspec_1_format_call_legacy_join() { + let named = |_: &str| None; + let joined = format_call(&[s("a"), Value::Integer(1)], true, &named); + assert_eq!(joined.unwrap(), "a 1"); + assert_eq!(format_call(&[s("x{{")], false, &named).unwrap(), "x{{"); + assert_eq!(format_call(&[s("x{{")], true, &named).unwrap(), "x{"); + assert_eq!(format_call(&[], true, &named).unwrap(), ""); +} + +#[test] +fn test_fmtspec_1_struct_and_map_debug() { + let mut fields = HashMap::new(); + fields.insert("a".to_string(), Value::Integer(1)); + let st = Value::Struct { + name: "P".to_string(), + fields: std::sync::Arc::new(fields.clone()), + }; + let map = Value::Object(std::sync::Arc::new(fields)); + let got = fmt("{:?}|{:#?}|{:?}|{:#?}", &[st.clone(), st, map.clone(), map]); + #[derive(Debug)] + #[allow(dead_code)] + struct P { + a: i64, + } + let m: std::collections::BTreeMap<&str, i64> = [("a", 1)].into_iter().collect(); + let want = format!("{:?}|{:#?}|{:?}|{:#?}", P { a: 1 }, P { a: 1 }, m, m); + assert_eq!(got.unwrap(), want); +} + +/// Rust's own output for a spec prefix chosen by index, with runtime width +/// and precision. +macro_rules! rust_ref { + ($idx:expr, $v:expr, $w:expr, $p:expr) => { + match $idx { + 0 => format!("{:w$.p$}", $v, w = $w, p = $p), + 1 => format!("{: format!("{:^w$.p$}", $v, w = $w, p = $p), + 3 => format!("{:>w$.p$}", $v, w = $w, p = $p), + 4 => format!("{:* format!("{:*^w$.p$}", $v, w = $w, p = $p), + 6 => format!("{:*>w$.p$}", $v, w = $w, p = $p), + 7 => format!("{:0w$.p$}", $v, w = $w, p = $p), + _ => format!("{:+w$.p$}", $v, w = $w, p = $p), + } + }; +} + +const PREFIXES: [&str; 9] = ["", "<", "^", ">", "*<", "*^", "*>", "0", "+"]; + +fn ours(idx: usize, value: &Value, w: usize, p: usize) -> String { + let spec = format!("{}{w}.{p}", PREFIXES[idx]); + fmt(&format!("{{:{spec}}}"), std::slice::from_ref(value)).expect("valid spec") +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(512))] + + #[test] + fn prop_fmtspec_1_ints_match_rust(idx in 0usize..9, v in -100_000i64..100_000, w in 1usize..14, p in 0usize..6) { + prop_assert_eq!(ours(idx, &Value::Integer(v), w, p), rust_ref!(idx, v, w, p)); + } + + #[test] + fn prop_fmtspec_1_floats_match_rust(idx in 0usize..9, v in -1.0e5f64..1.0e5, w in 1usize..14, p in 0usize..6) { + prop_assert_eq!(ours(idx, &Value::Float(v), w, p), rust_ref!(idx, v, w, p)); + } + + #[test] + fn prop_fmtspec_1_strings_match_rust(idx in 0usize..9, v in "[a-z]{0,8}", w in 1usize..14, p in 0usize..10) { + prop_assert_eq!(ours(idx, &s(&v), w, p), rust_ref!(idx, v.as_str(), w, p)); + } +} + +#[test] +fn test_fmtextra_1_extra_positional_argument_is_an_error() { + let err = fmt("{}", &[Value::Integer(1), Value::Integer(2)]).unwrap_err(); + assert!(err.contains("argument never used"), "{err}"); + assert!(err.contains("argument 1"), "{err}"); +} + +#[test] +fn test_fmtextra_1_unreferenced_explicit_index_is_an_error() { + let err = fmt("{0} {0}", &[Value::Integer(1), Value::Integer(2)]).unwrap_err(); + assert!(err.contains("argument 1"), "{err}"); + let err = fmt("{1}", &[Value::Integer(1), Value::Integer(2)]).unwrap_err(); + assert!(err.contains("argument 0"), "{err}"); +} + +#[test] +fn test_fmtextra_1_explicit_indices_count_as_uses() { + let args = [Value::Integer(1), Value::Integer(2)]; + assert_eq!( + fmt("{1} {0} {}", &args).unwrap(), + format!("{1} {0} {}", 1, 2) + ); + assert_eq!(fmt("{} {0}", &args[..1]).unwrap(), format!("{} {0}", 1)); + assert_eq!(fmt("{0}{}{}", &args).unwrap(), format!("{0}{}{}", 1, 2)); +} + +#[test] +fn test_fmtextra_1_named_fields_leave_positional_arguments_unused() { + let named = |n: &str| (n == "x").then_some(Value::Integer(5)); + let err = format_str("{x}", &[Value::Integer(1)], &named).unwrap_err(); + assert!(err.contains("argument never used"), "{err}"); + assert_eq!( + format_str("{x} {}", &[Value::Integer(1)], &named).unwrap(), + "5 1" + ); +} + +#[test] +fn test_fmtextra_1_format_call_template_with_extra_argument_errors() { + let named = |_: &str| None; + let values = [s("{}"), Value::Integer(1), Value::Integer(2)]; + assert!(format_call(&values, true, &named).is_err()); + assert_eq!(format_call(&values, false, &named).unwrap(), "{} 1 2"); +} diff --git a/src/runtime/interpreter.rs b/src/runtime/interpreter.rs index 7521af407..db1dbded0 100644 --- a/src/runtime/interpreter.rs +++ b/src/runtime/interpreter.rs @@ -277,7 +277,7 @@ impl Interpreter { match expr_kind { ExprKind::Binary { left, op, right } => self.eval_binary_expr(left, *op, right), ExprKind::Unary { op, operand } => self.eval_unary_expr(*op, operand), - ExprKind::Call { func, args } => self.eval_function_call(func, args), + ExprKind::Call { func, args } => self.eval_call_expr(func, args), ExprKind::MethodCall { receiver, method, @@ -373,11 +373,6 @@ impl Interpreter { Some(current_value) } - // Value formatting delegated to value_format module - // EXTREME TDD: Eliminated 50 lines of duplicate code - pub(crate) fn format_string_with_values(format_str: &str, values: &[Value]) -> String { - crate::runtime::value_format::format_string_with_values(format_str, values) - } /// Evaluate miscellaneous expressions. /// Delegates to `interpreter_misc_eval` module. pub(crate) fn eval_misc_expr( @@ -549,6 +544,8 @@ impl Interpreter { inclusive, } => self.eval_range_expr(start, end, *inclusive), ExprKind::ArrayInit { value, size } => self.eval_array_init_expr(value, size), + // STRRECV-1: `vec![x; n]` builds the same list as `[x; n]` + ExprKind::VecRepeat { value, count } => self.eval_array_init_expr(value, count), ExprKind::DataFrame { columns } => self.eval_dataframe_literal(columns), _ => unreachable!("Non-data-structure expression passed to eval_data_structure_expr"), } diff --git a/src/runtime/interpreter_control_flow.rs b/src/runtime/interpreter_control_flow.rs index 04aad4bde..87e145ba4 100644 --- a/src/runtime/interpreter_control_flow.rs +++ b/src/runtime/interpreter_control_flow.rs @@ -16,25 +16,40 @@ use crate::runtime::interpreter::{Interpreter, LoopControlOrError}; use crate::runtime::{InterpreterError, Value}; use std::sync::Arc; +/// The names a `for` loop binds per element: its pattern, or (for callers +/// without a parsed pattern) a single variable. +#[derive(Clone, Copy)] +pub(crate) struct ForBinding<'a> { + pub(crate) var: &'a str, + pub(crate) pattern: Option<&'a Pattern>, +} + +impl<'a> From<&'a str> for ForBinding<'a> { + fn from(var: &'a str) -> Self { + Self { var, pattern: None } + } +} + impl Interpreter { /// Evaluate a for loop pub(crate) fn eval_for_loop( &mut self, label: Option<&String>, var: &str, - _pattern: Option<&Pattern>, + pattern: Option<&Pattern>, iter: &Expr, body: &Expr, ) -> Result { let iter_value = self.eval_expr(iter)?; + let binding = ForBinding { var, pattern }; match iter_value { - Value::Array(ref arr) => self.eval_for_array_iteration(label, var, arr, body), + Value::Array(ref arr) => self.eval_for_array_iteration(label, binding, arr, body), Value::Range { ref start, ref end, inclusive, - } => self.eval_for_range_iteration(label, var, start, end, inclusive, body), + } => self.eval_for_range_iteration(label, binding, start, end, inclusive, body), _ => Err(InterpreterError::TypeError( "For loop requires an iterable".to_string(), )), @@ -46,15 +61,14 @@ impl Interpreter { pub(crate) fn eval_for_array_iteration( &mut self, label: Option<&String>, - loop_var: &str, + binding: ForBinding<'_>, arr: &[Value], body: &Expr, ) -> Result { let mut last_value = Value::nil(); for item in arr { - self.set_variable(loop_var, item.clone()); - match self.eval_loop_body_with_control_flow(body) { + match self.eval_for_iteration(binding, item.clone(), body) { Ok(value) => last_value = value, Err(LoopControlOrError::Break(break_label, break_val)) => { // If break has no label or matches this loop's label, break here @@ -90,7 +104,7 @@ impl Interpreter { pub(crate) fn eval_for_range_iteration( &mut self, label: Option<&String>, - loop_var: &str, + binding: ForBinding<'_>, start: &Value, end: &Value, inclusive: bool, @@ -100,8 +114,7 @@ impl Interpreter { let mut last_value = Value::nil(); for i in self.create_range_iterator(start_val, end_val, inclusive) { - self.set_variable(loop_var, Value::Integer(i)); - match self.eval_loop_body_with_control_flow(body) { + match self.eval_for_iteration(binding, Value::Integer(i), body) { Ok(value) => last_value = value, Err(LoopControlOrError::Break(break_label, break_val)) => { if break_label.is_none() || break_label.as_deref() == label.map(String::as_str) @@ -128,6 +141,45 @@ impl Interpreter { Ok(last_value) } + /// FORLEAK-1: one iteration of a `for` loop. The loop pattern is bound in + /// a scope of its own, so a same-named outer binding is shadowed, not + /// overwritten; assignments to outer variables in the body still reach + /// them. + fn eval_for_iteration( + &mut self, + binding: ForBinding<'_>, + item: Value, + body: &Expr, + ) -> Result { + self.push_scope(); + let result = self + .bind_for_item(binding, item) + .map_err(LoopControlOrError::Error) + .and_then(|()| self.eval_loop_body_with_control_flow(body)); + self.pop_scope(); + result + } + + /// Bind one loop element to the loop pattern in the current scope. + fn bind_for_item( + &mut self, + binding: ForBinding<'_>, + item: Value, + ) -> Result<(), InterpreterError> { + let bindings = match binding.pattern { + Some(pattern) => self.try_pattern_match(pattern, &item)?.ok_or_else(|| { + InterpreterError::RuntimeError(format!( + "for-loop pattern does not match element {item}" + )) + })?, + None => vec![(binding.var.to_string(), item)], + }; + for (name, value) in bindings { + self.env_set(name, value); + } + Ok(()) + } + /// Extract integer bounds from range values /// Complexity: ≤3 pub(crate) fn extract_range_bounds( @@ -423,234 +475,205 @@ impl Interpreter { } ExprKind::FieldAccess { object, field } => self.eval_field_assign(object, field, val), // BUG-003: Support array index assignment (arr[i] = value) - ExprKind::IndexAccess { object, index } => self.eval_index_assign(object, index, val), + ExprKind::IndexAccess { object, index } => self.eval_element_assign(object, index, val), _ => Err(InterpreterError::RuntimeError( "Invalid assignment target".to_string(), )), } } - /// BUG-003: Evaluate array/vector index assignment (arr[i] = value, matrix[i][j] = value) + /// Evaluate field assignment: `obj.field = value`. + /// + /// Handles Object, ObjectMut, Class, and Struct field updates. /// - /// Handles both simple (arr[0] = 99) and nested (matrix[0][1] = 99) index assignment. - /// Complexity: 8 (≤10 target) - /// Assign a value into a 2D array: `arr[outer_idx][inner_idx] = val`. - fn assign_nested_array( + /// NESTASSIGN-1: `object` may be a chain rooted at a variable + /// (`o.inner.z = 5`, `self.a.b = x`, `o.items[0].z = 5`): each value-typed + /// parent is rebuilt with the new field or element and written to its own + /// parent, ending at the variable. This is the single chain writer; the + /// in-place array methods on fields (`s.a.items.push(x)`) use it too. + pub(crate) fn eval_field_assign( &mut self, - arr_name: &str, - outer_idx: usize, - inner_idx_val: Value, + object: &Expr, + field: &str, val: Value, ) -> Result { - let inner_idx = match inner_idx_val { - Value::Integer(i) => i as usize, + let object = &self.freeze_place(object)?; + let parent = match &object.kind { + ExprKind::Identifier(obj_name) => self.lookup_variable(obj_name)?, + ExprKind::FieldAccess { .. } | ExprKind::IndexAccess { .. } => { + self.eval_expr(object)? + } _ => { return Err(InterpreterError::RuntimeError( - "Array index must be an integer".to_string(), + "Invalid field assignment target".to_string(), )) } }; - - let arr = self.lookup_variable(arr_name)?; - let Value::Array(ref outer_vec) = arr else { - return Err(InterpreterError::RuntimeError( - "Cannot index non-array value".to_string(), - )); - }; - - let mut new_outer = outer_vec.to_vec(); - if outer_idx >= new_outer.len() { - return Err(InterpreterError::RuntimeError(format!( - "Outer index {outer_idx} out of bounds" - ))); - } - - let Value::Array(ref inner_vec) = new_outer[outer_idx] else { - return Err(InterpreterError::RuntimeError( - "Cannot index non-array value".to_string(), - )); - }; - - let mut new_inner = inner_vec.to_vec(); - if inner_idx >= new_inner.len() { - return Err(InterpreterError::RuntimeError(format!( - "Inner index {inner_idx} out of bounds" - ))); - } - - new_inner[inner_idx] = val.clone(); - new_outer[outer_idx] = Value::Array(Arc::from(new_inner)); - self.set_variable(arr_name, Value::Array(Arc::from(new_outer))); + let updated = super::interpreter_methods_dispatch::with_field(parent, field, val.clone())?; + self.write_back(object, updated)?; Ok(val) } - /// Evaluate field assignment: `obj.field = value`. - /// - /// Handles Object, ObjectMut, Class, and Struct field updates. - fn eval_field_assign( + /// NESTASSIGN-1: store `updated` at the place `target` names: a variable, + /// a field of a chain, or an element of an array. + pub(crate) fn write_back( &mut self, - object: &Expr, - field: &str, - val: Value, + target: &Expr, + updated: Value, ) -> Result { - let ExprKind::Identifier(obj_name) = &object.kind else { - return Err(InterpreterError::RuntimeError( - "Complex field access not supported".to_string(), - )); - }; - let obj = self.lookup_variable(obj_name)?; - - match obj { - Value::Object(ref map) => { - let mut new_map = (**map).clone(); - new_map.insert(field.to_string(), val.clone()); - self.set_variable(obj_name, Value::Object(Arc::new(new_map))); - Ok(val) - } - Value::ObjectMut(ref cell) => { - cell.lock() - .expect("Mutex poisoned: object lock is corrupted") - .insert(field.to_string(), val.clone()); - Ok(val) + match &target.kind { + ExprKind::Identifier(name) => { + self.set_variable(name, updated.clone()); + Ok(updated) } - Value::Class { ref fields, .. } => { - fields - .write() - .expect("RwLock poisoned: class fields lock is corrupted") - .insert(field.to_string(), val.clone()); - Ok(val) + ExprKind::FieldAccess { object, field } => { + self.eval_field_assign(object, field, updated) } - Value::Struct { - ref name, - ref fields, - } => { - let mut new_fields = (**fields).clone(); - new_fields.insert(field.to_string(), val.clone()); - let new_struct = Value::Struct { - name: name.clone(), - fields: Arc::new(new_fields), - }; - self.set_variable(obj_name, new_struct); - Ok(val) + ExprKind::IndexAccess { object, index } => { + self.eval_element_assign(object, index, updated) } - _ => Err(InterpreterError::RuntimeError(format!( - "Cannot access field '{field}' on non-object" - ))), + _ => Err(InterpreterError::RuntimeError( + "Invalid assignment target".to_string(), + )), } } - pub(crate) fn eval_index_assign( + /// Element assignment `object[index] = val`: delegates to + /// [`Self::eval_index_assign`], the single element writer. + fn eval_element_assign( &mut self, object: &Expr, index: &Expr, val: Value, ) -> Result { - match &object.kind { - ExprKind::Identifier(arr_name) => { - // Simple case: arr[i] = value - let idx_val = self.eval_expr(index)?; - let idx = match idx_val { - Value::Integer(i) => i as usize, - _ => { - return Err(InterpreterError::RuntimeError( - "Array index must be an integer".to_string(), - )) - } - }; - - let arr = self.lookup_variable(arr_name)?; - match arr { - Value::Array(ref vec) => { - let mut new_vec = vec.to_vec(); - if idx < new_vec.len() { - new_vec[idx] = val.clone(); - self.set_variable(arr_name, Value::Array(Arc::from(new_vec))); - Ok(val) - } else { - Err(InterpreterError::RuntimeError(format!( - "Index {} out of bounds for array of length {}", - idx, - new_vec.len() - ))) - } - } - _ => Err(InterpreterError::RuntimeError( - "Cannot index non-array value".to_string(), - )), - } - } - ExprKind::IndexAccess { - object: nested_obj, - index: nested_idx, - } => { - // Nested case: matrix[i][j] = value - // Get the outer index first - let outer_idx_val = self.eval_expr(nested_idx)?; - let outer_idx = match outer_idx_val { - Value::Integer(i) => i as usize, - _ => { - return Err(InterpreterError::RuntimeError( - "Array index must be an integer".to_string(), - )) - } - }; + self.eval_index_assign(object, index, val) + } - // Get the root array name (only handle Identifier for now) - if let ExprKind::Identifier(arr_name) = &nested_obj.kind { - let inner_idx_val = self.eval_expr(index)?; - self.assign_nested_array(arr_name, outer_idx, inner_idx_val, val) - } else { - Err(InterpreterError::RuntimeError( - "Complex nested index assignment not yet supported".to_string(), - )) - } - } - _ => Err(InterpreterError::RuntimeError( - "Complex array assignment targets not yet supported".to_string(), - )), - } + /// BUG-003 / NESTASSIGN-1 / DICTIDX-1: `object[index] = val` where + /// `object` is any place (`v`, `m[0]`, `o.items`, `d["k"]["j"]`). The + /// container is read, rebuilt with the new element (an integer index into + /// an array, or a string key into an object/dict) and written back + /// through `write_back`, so every link of the chain is updated. + /// Complexity: 1 + pub(crate) fn eval_index_assign( + &mut self, + object: &Expr, + index: &Expr, + val: Value, + ) -> Result { + let object = &self.freeze_place(object)?; + let container = self.eval_expr(object)?; + let key = self.eval_expr(index)?; + let updated = with_element(container, &key, val.clone())?; + self.write_back(object, updated)?; + Ok(val) } - /// Evaluate a compound assignment - /// Complexity: 6 + /// Evaluate a compound assignment (`x += 1`, `o.a.b -= 2`, `v[i] *= 3`, + /// `o.items[0].z %= 4`). + /// + /// IDXCOMPOUND-1: the target may be any place `write_back` accepts: a + /// variable, or a FieldAccess/IndexAccess chain rooted at one. The current + /// value is read through the chain and the result is written back through + /// the same path plain assignment uses. + /// Complexity: 2 pub(crate) fn eval_compound_assign( &mut self, target: &Expr, op: AstBinaryOp, value: &Expr, ) -> Result { - // Get current value - let current = match &target.kind { - ExprKind::Identifier(name) => self.lookup_variable(name)?, - ExprKind::FieldAccess { object, field } => self.eval_field_access(object, field)?, - _ => { - return Err(InterpreterError::RuntimeError( - "Invalid compound assignment target".to_string(), - )) - } - }; - - // Compute new value + if !is_place_expr(target) { + return Err(InterpreterError::RuntimeError( + "Invalid compound assignment target".to_string(), + )); + } + let target = &self.freeze_place(target)?; + let current = self.eval_expr(target)?; let rhs = self.eval_expr(value)?; let new_val = self.apply_binary_op(¤t, op, &rhs)?; + self.write_back(target, new_val.clone())?; + Ok(new_val) + } - // Assign back - match &target.kind { - ExprKind::Identifier(name) => { - self.set_variable(name, new_val.clone()); - } - ExprKind::FieldAccess { object, field } => { - // Reuse eval_field_assign which handles all object types - self.eval_field_assign(object, field, new_val.clone())?; - } - _ => { - return Err(InterpreterError::RuntimeError( - "Complex assignment targets not supported in compound assignment".to_string(), - )) + /// IDXEVAL1-1: `place` with every index expression of its chain replaced + /// by the literal of its value, evaluated once, left to right. Reading the + /// frozen place and writing it back then runs no index expression again, + /// so `v[next()] += 1` and `m[f()][j] = x` call `next`/`f` once, as + /// compiled code does. An index value with no literal form (a range, a + /// float) is left as written; the write rejects such a key anyway. + /// Complexity: 3 + pub(crate) fn freeze_place(&mut self, place: &Expr) -> Result { + let mut frozen = place.clone(); + match &mut frozen.kind { + ExprKind::FieldAccess { object, .. } => **object = self.freeze_place(object)?, + ExprKind::IndexAccess { object, index } => { + **object = self.freeze_place(object)?; + let key = self.eval_expr(index)?; + if let Some(literal) = key_literal(&key) { + index.kind = ExprKind::Literal(literal); + } } + _ => {} } + Ok(frozen) + } +} - Ok(new_val) +/// IDXEVAL1-1: the literal that evaluates to the index `key`, for the key +/// types an element write accepts (an integer, a string, an atom). +/// Complexity: 4 +fn key_literal(key: &Value) -> Option { + match key { + Value::Integer(i) => Some(Literal::Integer(*i, None)), + Value::String(s) => Some(Literal::String(s.to_string())), + Value::Atom(a) => Some(Literal::Atom(a.clone())), + _ => None, + } +} + +/// DICTIDX-1: `container` with the element at `key` replaced by `val`: an +/// integer index into an array, or a string key into an object/dict. +/// Complexity: 4 +fn with_element(container: Value, key: &Value, val: Value) -> Result { + match (&container, key) { + (Value::Array(items), Value::Integer(i)) => with_array_slot(items, *i, val), + (Value::Array(_), _) => Err(InterpreterError::RuntimeError( + "Array index must be an integer".to_string(), + )), + (_, Value::String(k)) => { + super::interpreter_methods_dispatch::with_field(container.clone(), k, val) + } + _ => Err(InterpreterError::RuntimeError( + "Cannot index non-array value".to_string(), + )), + } +} + +/// `items` with slot `i` replaced by `val`; an out-of-range index is an error. +/// Complexity: 2 +fn with_array_slot(items: &[Value], i: i64, val: Value) -> Result { + let mut items = items.to_vec(); + let len = items.len(); + let Some(slot) = usize::try_from(i).ok().and_then(|i| items.get_mut(i)) else { + return Err(InterpreterError::RuntimeError(format!( + "Index {i} out of bounds for array of length {len}" + ))); + }; + *slot = val; + Ok(Value::Array(Arc::from(items))) +} + +/// IDXCOMPOUND-1 / IDXPUSHWB-1: `expr` names a place: a variable, or a +/// FieldAccess/IndexAccess chain whose root is a variable. +/// Complexity: 3 +pub(crate) fn is_place_expr(expr: &Expr) -> bool { + match &expr.kind { + ExprKind::Identifier(_) => true, + ExprKind::FieldAccess { object, .. } | ExprKind::IndexAccess { object, .. } => { + is_place_expr(object) + } + _ => false, } } @@ -742,7 +765,7 @@ mod tests { let body = make_expr(ExprKind::Literal(Literal::Integer(42, None))); let result = interp - .eval_for_array_iteration(None, "x", &[], &body) + .eval_for_array_iteration(None, "x".into(), &[], &body) .unwrap(); assert_eq!(result, Value::nil()); } @@ -757,7 +780,7 @@ mod tests { let body = make_expr(ExprKind::Identifier("x".to_string())); let result = interp - .eval_for_array_iteration(None, "x", &arr, &body) + .eval_for_array_iteration(None, "x".into(), &arr, &body) .unwrap(); // Last value is 3 assert_eq!(result, Value::Integer(3)); @@ -776,7 +799,7 @@ mod tests { let body = make_expr(ExprKind::Identifier("i".to_string())); let result = interp - .eval_for_range_iteration(None, "i", &start, &end, false, &body) + .eval_for_range_iteration(None, "i".into(), &start, &end, false, &body) .unwrap(); // Last value is 2 (exclusive) assert_eq!(result, Value::Integer(2)); @@ -791,7 +814,7 @@ mod tests { let body = make_expr(ExprKind::Identifier("i".to_string())); let result = interp - .eval_for_range_iteration(None, "i", &start, &end, true, &body) + .eval_for_range_iteration(None, "i".into(), &start, &end, true, &body) .unwrap(); // Last value is 3 (inclusive) assert_eq!(result, Value::Integer(3)); @@ -1556,7 +1579,10 @@ mod tests { let result = interp.eval_index_assign(&outer_access, &outer_idx, Value::Integer(99)); assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("Outer index")); + assert!(result + .unwrap_err() + .to_string() + .contains("Index 5 out of bounds")); } #[test] @@ -1578,7 +1604,10 @@ mod tests { let result = interp.eval_index_assign(&outer_access, &outer_idx, Value::Integer(99)); assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("Inner index")); + assert!(result + .unwrap_err() + .to_string() + .contains("Index 5 out of bounds")); } #[test] @@ -1616,7 +1645,7 @@ mod tests { assert!(result .unwrap_err() .to_string() - .contains("not yet supported")); + .contains("Cannot index non-array")); } // ============================================================================ @@ -1701,7 +1730,7 @@ mod tests { } #[test] - fn test_eval_assign_complex_field_target() { + fn test_eval_assign_nested_field_target_writes_back() { let mut interp = make_interpreter(); // Set up nested object @@ -1711,22 +1740,72 @@ mod tests { outer.insert("inner".to_string(), Value::Object(Arc::new(inner))); interp.set_variable("obj", Value::Object(Arc::new(outer))); - // obj.inner.z = 5 -- complex target (object is a FieldAccess, not Identifier) - let target = make_expr(ExprKind::FieldAccess { + // NESTASSIGN-1: obj.inner.z = 5 writes through `inner` back to `obj` + let target = nested_z_target("obj"); + let value = make_expr(ExprKind::Literal(Literal::Integer(5, None))); + + let result = interp.eval_assign(&target, &value); + assert_eq!(result.expect("nested field assign"), Value::Integer(5)); + assert_eq!(nested_z(&mut interp), Value::Integer(5)); + } + + /// `.inner.z` as an assignment target. + fn nested_z_target(root: &str) -> Expr { + make_expr(ExprKind::FieldAccess { object: Box::new(make_expr(ExprKind::FieldAccess { - object: Box::new(make_expr(ExprKind::Identifier("obj".to_string()))), + object: Box::new(make_expr(ExprKind::Identifier(root.to_string()))), field: "inner".to_string(), })), field: "z".to_string(), - }); + }) + } + + /// The current value of `obj.inner.z`. + fn nested_z(interp: &mut Interpreter) -> Value { + let target = nested_z_target("obj"); + interp.eval_expr(&target).expect("obj.inner.z is readable") + } + + #[test] + fn test_eval_assign_field_on_non_object_is_error() { + let mut interp = make_interpreter(); + let mut outer = HashMap::new(); + outer.insert("inner".to_string(), Value::Integer(3)); + interp.set_variable("obj", Value::Object(Arc::new(outer))); + + // obj.inner.z = 5 where obj.inner is an integer let value = make_expr(ExprKind::Literal(Literal::Integer(5, None))); + let err = interp + .eval_assign(&nested_z_target("obj"), &value) + .expect_err("field of an integer is not assignable"); + assert!(err.to_string().contains("non-object"), "{err}"); + // the failed assignment leaves obj unchanged + let inner = make_expr(ExprKind::FieldAccess { + object: Box::new(make_expr(ExprKind::Identifier("obj".to_string()))), + field: "inner".to_string(), + }); + assert_eq!( + interp.eval_expr(&inner).expect("obj.inner"), + Value::Integer(3) + ); + } - let result = interp.eval_assign(&target, &value); - assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("Complex field access not supported")); + #[test] + fn test_eval_assign_field_on_call_result_is_error() { + let mut interp = make_interpreter(); + // (1).z = 5: the object is neither a variable nor a chain rooted at one + let target = make_expr(ExprKind::FieldAccess { + object: Box::new(make_expr(ExprKind::Literal(Literal::Integer(1, None)))), + field: "z".to_string(), + }); + let value = make_expr(ExprKind::Literal(Literal::Integer(5, None))); + let err = interp + .eval_assign(&target, &value) + .expect_err("a literal has no assignable field"); + assert!( + err.to_string().contains("Invalid field assignment target"), + "{err}" + ); } #[test] @@ -1812,7 +1891,7 @@ mod tests { } #[test] - fn test_eval_compound_assign_complex_field_access_error() { + fn test_eval_compound_assign_nested_field() { let mut interp = make_interpreter(); // Set up nested object @@ -1822,22 +1901,13 @@ mod tests { outer.insert("inner".to_string(), Value::Object(Arc::new(inner))); interp.set_variable("obj", Value::Object(Arc::new(outer))); - // obj.inner.z += 1 -- complex field access not supported - let target = make_expr(ExprKind::FieldAccess { - object: Box::new(make_expr(ExprKind::FieldAccess { - object: Box::new(make_expr(ExprKind::Identifier("obj".to_string()))), - field: "inner".to_string(), - })), - field: "z".to_string(), - }); + // NESTASSIGN-1: obj.inner.z += 1 reads and writes through the chain + let target = nested_z_target("obj"); let value = make_expr(ExprKind::Literal(Literal::Integer(1, None))); let result = interp.eval_compound_assign(&target, AstBinaryOp::Add, &value); - assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("Complex field access not supported")); + assert_eq!(result.expect("nested compound assign"), Value::Integer(6)); + assert_eq!(nested_z(&mut interp), Value::Integer(6)); } #[test] diff --git a/src/runtime/interpreter_core_tests_part1.rs b/src/runtime/interpreter_core_tests_part1.rs index db26f6453..f9991d97e 100644 --- a/src/runtime/interpreter_core_tests_part1.rs +++ b/src/runtime/interpreter_core_tests_part1.rs @@ -381,7 +381,7 @@ fn test_env_set_mut_updates_parent() { // Test format_string_with_values #[test] fn test_format_string_with_values() { - let result = Interpreter::format_string_with_values( + let result = crate::runtime::value_format::format_string_with_values( "Hello {} and {}!", &[Value::from_string("world".to_string()), Value::Integer(42)], ); @@ -390,7 +390,7 @@ fn test_format_string_with_values() { #[test] fn test_format_string_with_values_fewer_args() { - let result = Interpreter::format_string_with_values( + let result = crate::runtime::value_format::format_string_with_values( "Hello {} and {}!", &[Value::from_string("world".to_string())], ); diff --git a/src/runtime/interpreter_core_tests_part2.rs b/src/runtime/interpreter_core_tests_part2.rs index c746fdb35..0724f546e 100644 --- a/src/runtime/interpreter_core_tests_part2.rs +++ b/src/runtime/interpreter_core_tests_part2.rs @@ -958,8 +958,9 @@ fn test_format_debug_incomplete() { "{:?x".to_string(), )))], }); - let result = interp.eval_expr(&expr).unwrap(); - assert!(matches!(result, Value::String(_))); + // FMTSPEC-1: an unterminated field is an error, as in rustc. + let err = interp.eval_expr(&expr).unwrap_err(); + assert!(err.to_string().contains("unterminated"), "{err}"); } #[test] @@ -972,8 +973,9 @@ fn test_format_colon_only() { "{:x".to_string(), )))], }); - let result = interp.eval_expr(&expr).unwrap(); - assert!(matches!(result, Value::String(_))); + // FMTSPEC-1: an unterminated field is an error, as in rustc. + let err = interp.eval_expr(&expr).unwrap_err(); + assert!(err.to_string().contains("unterminated"), "{err}"); } #[test] @@ -986,12 +988,9 @@ fn test_format_excess_placeholders() { "{} {} {}".to_string(), )))], }); - let result = interp.eval_expr(&expr).unwrap(); - if let Value::String(s) = result { - assert!(s.as_ref().contains("{}")); - } else { - panic!("Expected String"); - } + // FMTSPEC-1: a field with no argument is an error, as in rustc. + let err = interp.eval_expr(&expr).unwrap_err(); + assert!(err.to_string().contains("positional argument"), "{err}"); } // ===== Import Tests ===== diff --git a/src/runtime/interpreter_format_calls.rs b/src/runtime/interpreter_format_calls.rs new file mode 100644 index 000000000..cee57aa76 --- /dev/null +++ b/src/runtime/interpreter_format_calls.rs @@ -0,0 +1,208 @@ +//! FMTSPEC-1 / EPRINTLN-1 / FORMATFN-1: the output and format calls +//! (`println`, `print`, `eprintln`, `eprint`, `format`, function or macro +//! form) all format through [`crate::runtime::fmt_spec`]. +//! +//! Calls made through the interpreter resolve `{name}` against explicit named +//! arguments (`name = expr`) and then variables in scope, as Rust 2021 inline +//! format arguments do. A string literal first argument is always a format +//! string, as in Rust. + +use crate::frontend::ast::{Expr, ExprKind, Literal}; +use crate::runtime::fmt_spec; +use crate::runtime::interpreter::Interpreter; +use crate::runtime::{InterpreterError, Value}; +use std::io::Write; + +/// Where the formatted text goes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FormatSink { + Print, + Println, + Eprint, + Eprintln, + Return, +} + +impl FormatSink { + /// The sink of a builtin output/format function or macro name. + pub fn for_name(name: &str) -> Option { + match name { + "print" => Some(Self::Print), + "println" => Some(Self::Println), + "eprint" => Some(Self::Eprint), + "eprintln" => Some(Self::Eprintln), + "format" => Some(Self::Return), + _ => None, + } + } + + /// The sink of a `__builtin_*__` marker. + pub fn for_marker(marker: &str) -> Option { + let name = marker.strip_prefix("__builtin_")?.strip_suffix("__")?; + Self::for_name(name) + } + + #[cfg(test)] + fn marker(self) -> &'static str { + match self { + Self::Print => "__builtin_print__", + Self::Println => "__builtin_println__", + Self::Eprint => "__builtin_eprint__", + Self::Eprintln => "__builtin_eprintln__", + Self::Return => "__builtin_format__", + } + } +} + +/// Deliver `text` to `sink`; `format` returns it as a string value. +pub fn emit(sink: FormatSink, text: String) -> Value { + match sink { + FormatSink::Return => return Value::from_string(text), + FormatSink::Print => write_stdout(&text), + FormatSink::Println => write_stdout(&format!("{text}\n")), + FormatSink::Eprint => eprint!("{text}"), + FormatSink::Eprintln => eprintln!("{text}"), + } + Value::Nil +} + +fn write_stdout(text: &str) { + // Notebook capture reads the output buffer. + if let Ok(mut buf) = crate::runtime::builtins::OUTPUT_BUFFER.lock() { + buf.push_str(text); + } + print!("{text}"); + let _ = std::io::stdout().flush(); +} + +fn format_error(e: String) -> InterpreterError { + InterpreterError::RuntimeError(format!("format error: {e}")) +} + +/// The builtin path (no scope and no argument expressions): a format builtin +/// called where only values exist, e.g. from a non-identifier callee. Calls +/// through a name take `Interpreter::eval_format_call` (FORMATVAL-1); here +/// the first value is a format string when more values follow it. +pub fn eval_builtin_format(sink: FormatSink, args: &[Value]) -> Result { + let template = args.len() > 1; + let text = fmt_spec::format_call(args, template, &|_| None).map_err(format_error)?; + Ok(emit(sink, text)) +} + +fn is_string_literal(expr: &Expr) -> bool { + matches!(expr.kind, ExprKind::Literal(Literal::String(_))) +} + +/// `name = expr` in argument position (not the first argument). +fn named_arg(expr: &Expr) -> Option<(&str, &Expr)> { + match &expr.kind { + ExprKind::Assign { target, value } => match &target.kind { + ExprKind::Identifier(name) => Some((name.as_str(), value.as_ref())), + _ => None, + }, + _ => None, + } +} + +type NamedArgs = Vec<(String, Value)>; + +impl Interpreter { + /// A call expression. Output/format builtins that the program has not + /// shadowed format with scope access; every other call is ordinary. + pub(crate) fn eval_call_expr( + &mut self, + func: &Expr, + args: &[Expr], + ) -> Result { + match self.format_sink_for(func) { + Some(sink) => self.eval_format_call(sink, args), + None => self.eval_function_call(func, args), + } + } + + /// FORMATVAL-1: any identifier bound to an output/format builtin + /// (`println`, or `g` after `let g = format`, or a parameter holding + /// one) formats with the argument expressions, so the FMTTEMPLATE-1 + /// literal rule applies however the builtin is reached by name. + fn format_sink_for(&self, func: &Expr) -> Option { + let ExprKind::Identifier(name) = &func.kind else { + return None; + }; + match self.lookup_variable(name) { + Ok(Value::String(marker)) => FormatSink::for_marker(&marker), + _ => None, + } + } + + /// Format `args` (a format string and its arguments) and deliver the text. + pub(crate) fn eval_format_call( + &mut self, + sink: FormatSink, + args: &[Expr], + ) -> Result { + let (positional, named) = self.eval_format_args(args)?; + // FMTTEMPLATE-1: only a string literal is a format template, as in + // the transpiler; `println(s, 1)` prints its arguments joined by + // spaces. LONELIT-1: a lone literal is a template only for + // `format` (`format!(lit)`); println/print print it verbatim, as the + // transpiler escapes its braces. + let template = args.first().is_some_and(is_string_literal) + && (args.len() > 1 || matches!(sink, FormatSink::Return)); + let resolve = |name: &str| { + named + .iter() + .find(|(n, _)| n == name) + .map(|(_, v)| v.clone()) + .or_else(|| self.lookup_variable(name).ok()) + }; + let text = fmt_spec::format_call(&positional, template, &resolve).map_err(format_error)?; + Ok(emit(sink, text)) + } + + fn eval_format_args( + &mut self, + args: &[Expr], + ) -> Result<(Vec, NamedArgs), InterpreterError> { + let mut positional = Vec::new(); + let mut named = Vec::new(); + for (i, arg) in args.iter().enumerate() { + match named_arg(arg).filter(|_| i > 0) { + Some((name, value)) => named.push((name.to_string(), self.eval_expr(value)?)), + None => positional.push(self.eval_expr(arg)?), + } + } + Ok((positional, named)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_fmtspec_1_sink_names_and_markers() { + for name in ["print", "println", "eprint", "eprintln", "format"] { + let sink = FormatSink::for_name(name).expect(name); + assert_eq!(FormatSink::for_marker(sink.marker()), Some(sink)); + } + assert_eq!(FormatSink::for_name("dbg"), None); + assert_eq!(FormatSink::for_marker("println"), None); + } + + #[test] + fn test_formatfn_1_builtin_format_returns_string() { + let args = [ + Value::from_string("{:?}".to_string()), + Value::from_array(vec![Value::Integer(1), Value::Integer(2)]), + ]; + let got = eval_builtin_format(FormatSink::Return, &args).unwrap(); + assert_eq!(got, Value::from_string("[1, 2]".to_string())); + } + + #[test] + fn test_fmtspec_1_builtin_format_error_is_runtime_error() { + let args = [Value::from_string("{:1$}".to_string()), Value::Integer(1)]; + let err = eval_builtin_format(FormatSink::Return, &args).unwrap_err(); + assert!(format!("{err:?}").contains("1$")); + } +} diff --git a/src/runtime/interpreter_methods_dispatch.rs b/src/runtime/interpreter_methods_dispatch.rs index 5b42f7005..f748336c9 100644 --- a/src/runtime/interpreter_methods_dispatch.rs +++ b/src/runtime/interpreter_methods_dispatch.rs @@ -15,7 +15,174 @@ use crate::runtime::interpreter::Interpreter; use crate::runtime::{InterpreterError, Value}; use std::sync::Arc; +use crate::runtime::eval_array::{apply_in_place_array_method, is_in_place_array_method}; +use crate::runtime::interpreter_control_flow::is_place_expr; + +/// Which handler an object's dispatch marker selects, checked in the order +/// actor, class, struct, `__type`. +enum ObjectKind { + Actor(String), + Class(String), + Struct(String), + Typed(String), + Plain, +} + +impl ObjectKind { + /// Complexity: 5 + fn of(obj: &std::collections::HashMap) -> Self { + let marker = |key: &str| match obj.get(key) { + Some(Value::String(s)) => Some(s.to_string()), + _ => None, + }; + if let Some(name) = marker("__actor") { + return Self::Actor(name); + } + if let Some(name) = marker("__class") { + return Self::Class(name); + } + if let Some(name) = marker("__struct_type").or_else(|| marker("__struct")) { + return Self::Struct(name); + } + marker("__type").map_or(Self::Plain, Self::Typed) + } +} + +/// RHLGA-1 F6: `obj` with `field` set to `val`: a copy for value types, the same +/// shared value (updated in place) for `ObjectMut` and `Class`. +pub(crate) fn with_field(obj: Value, field: &str, val: Value) -> Result { + match obj { + Value::Object(map) => { + let mut new_map = (*map).clone(); + new_map.insert(field.to_string(), val); + Ok(Value::Object(Arc::new(new_map))) + } + Value::ObjectMut(ref cell) => { + cell.lock() + .expect("Mutex poisoned: object lock is corrupted") + .insert(field.to_string(), val); + Ok(obj) + } + Value::Class { ref fields, .. } => { + fields + .write() + .expect("RwLock poisoned: class fields lock is corrupted") + .insert(field.to_string(), val); + Ok(obj) + } + Value::Struct { name, fields } => { + let mut new_fields = (*fields).clone(); + new_fields.insert(field.to_string(), val); + Ok(Value::Struct { + name, + fields: Arc::new(new_fields), + }) + } + _ => Err(InterpreterError::RuntimeError(format!( + "Cannot access field '{field}' on non-object" + ))), + } +} + impl Interpreter { + /// FIELDPOP-1 / ARRAYMUT-1: an in-place array method on a field + /// (`self.items.pop()`, `s.items.sort()`, `o.items.insert(0, x)`) behaves + /// like the same call on a local array: the call returns the method's own + /// result and the changed array is written back to the field. The field + /// may be nested (`self.inner.items.push(x)`, `s.a.b.sort()`). + /// + /// IDXPUSHWB-1: the receiver may be any FieldAccess/IndexAccess chain + /// rooted at a variable (`m[0].push(x)`, `t.rows[i].vals.sort()`); the + /// changed array is stored through `write_back`, the NESTASSIGN-1 writer. + /// + /// Returns `Ok(None)` when the call is not of that shape, so the caller + /// falls through to ordinary method dispatch. + fn try_chain_array_mutation( + &mut self, + receiver: &Expr, + method: &str, + args: &[Expr], + ) -> Result, InterpreterError> { + let is_chain = matches!( + receiver.kind, + ExprKind::FieldAccess { .. } | ExprKind::IndexAccess { .. } + ); + if !is_chain || !is_place_expr(receiver) || !is_in_place_array_method(method, args.len()) { + return Ok(None); + } + let receiver = &self.freeze_place(receiver)?; + let Value::Array(arr) = self.eval_expr(receiver)? else { + return Ok(None); + }; + let (items, result) = self.apply_in_place_call(&arr, method, args)?; + self.write_back(receiver, Value::Array(Arc::from(items)))?; + Ok(Some(result)) + } + + /// ARRAYMUT-1: an in-place array method on a local array variable + /// (`v.push(x)`, `v.pop()`, `v.sort()`, `v.remove(i)`, …) returns the + /// method's own result and rebinds the variable to the changed array. + /// `env_set_mut` updates the binding in an enclosing scope too, so the + /// change survives a call made inside a loop body. + /// + /// Returns `Ok(None)` when the receiver is not an array variable or the + /// method is not in-place, so the caller falls through. + fn try_local_array_mutation( + &mut self, + var_name: &str, + method: &str, + args: &[Expr], + ) -> Result, InterpreterError> { + if !is_in_place_array_method(method, args.len()) { + return Ok(None); + } + let Ok(Value::Array(arr)) = self.lookup_variable(var_name) else { + return Ok(None); + }; + let (items, result) = self.apply_in_place_call(&arr, method, args)?; + self.env_set_mut(var_name.to_string(), Value::Array(Arc::from(items))); + Ok(Some(result)) + } + + /// RHLGA-1 F5: a method of a user `impl` on an enum value + /// (`Maybe::Some(3).unwrap()`) runs with the variant bound to `self`. + /// `None` when the enum has no such method. + fn eval_enum_instance_method( + &mut self, + receiver: &Value, + enum_name: &str, + method: &str, + arg_values: &[Value], + ) -> Option> { + let closure = self + .lookup_variable(&format!("{enum_name}::{method}")) + .ok()?; + if !matches!(closure, Value::Closure { .. }) { + return None; + } + let args: Vec = std::iter::once(receiver.clone()) + .chain(arg_values.iter().cloned()) + .collect(); + Some(self.call_function(closure, &args)) + } + + /// Evaluate `args`, then apply the in-place `method` to a copy of `arr`. + /// Returns the changed elements and the method's result. + fn apply_in_place_call( + &mut self, + arr: &[Value], + method: &str, + args: &[Expr], + ) -> Result<(Vec, Value), InterpreterError> { + let arg_values = args + .iter() + .map(|arg| self.eval_expr(arg)) + .collect::, _>>()?; + let mut items = arr.to_vec(); + let result = apply_in_place_array_method(&mut items, method, &arg_values)?; + Ok((items, result)) + } + /// Evaluate a method call pub(crate) fn eval_method_call( &mut self, @@ -30,69 +197,19 @@ impl Interpreter { } } - // Special handling for mutating array methods on simple identifiers - // e.g., messages.push(item) + // ARRAYMUT-1: in-place array methods on a local array variable + // (messages.push(item), v.sort(), v.remove(0)) mutate the binding if let ExprKind::Identifier(var_name) = &receiver.kind { - if method == "push" && args.len() == 1 { - // Get current array value - if let Ok(Value::Array(arr)) = self.lookup_variable(var_name) { - // Evaluate the argument - let arg_value = self.eval_expr(&args[0])?; - - // Create new array with item added - let mut new_arr = arr.to_vec(); - new_arr.push(arg_value); - - // Update the variable binding - CRITICAL: Use env_set_mut to update - // in parent scopes (e.g., when push is called inside while loops) - self.env_set_mut(var_name.clone(), Value::Array(Arc::from(new_arr))); - - return Ok(Value::Nil); // push returns nil - } - } else if method == "pop" && args.is_empty() { - // Get current array value - if let Ok(Value::Array(arr)) = self.lookup_variable(var_name) { - // Create new array with last item removed - let mut new_arr = arr.to_vec(); - let popped_value = new_arr.pop().unwrap_or(Value::Nil); - - // Update the variable binding - CRITICAL: Use env_set_mut to update - // in parent scopes (e.g., when pop is called inside while loops) - self.env_set_mut(var_name.clone(), Value::Array(Arc::from(new_arr))); - - return Ok(popped_value); // pop returns the removed item - } + if let Some(result) = self.try_local_array_mutation(var_name, method, args)? { + return Ok(result); } } - // Special handling for mutating array methods on ObjectMut fields - // e.g., self.messages.push(item) - if let ExprKind::FieldAccess { object, field } = &receiver.kind { - if let Ok(object_value) = self.eval_expr(object) { - if let Value::ObjectMut(cell_rc) = object_value { - // Check if this is a mutating array method - if method == "push" && args.len() == 1 { - // Evaluate the argument - let arg_value = self.eval_expr(&args[0])?; - - // Get mutable access to the object - let mut obj = cell_rc - .lock() - .expect("Mutex poisoned: object lock is corrupted"); - - // Get the field value - if let Some(field_value) = obj.get(field) { - // If it's an array, push to it - if let Value::Array(arr) = field_value { - let mut new_arr = arr.to_vec(); - new_arr.push(arg_value); - obj.insert(field.clone(), Value::Array(Arc::from(new_arr))); - return Ok(Value::Nil); // push returns nil - } - } - } - } - } + // FIELDPOP-1 / IDXPUSHWB-1: mutating array methods on a field or an + // element (self.items.pop(), m[0].push(x)) return their own result + // and write the changed array back through the chain + if let Some(result) = self.try_chain_array_mutation(receiver, method, args)? { + return Ok(result); } let receiver_value = self.eval_expr(receiver)?; @@ -275,12 +392,55 @@ impl Interpreter { } } + /// Dispatch a method call on an evaluated receiver. + /// + /// OPTMETHODS-1: `Some`/`None`/`Ok`/`Err`/nil receivers try the `Option` + /// methods first; any other receiver reaches them only after its own type + /// reported the method unknown, so no existing method is shadowed. + /// + /// Complexity: 4 pub(crate) fn dispatch_method_call( &mut self, receiver: &Value, method: &str, arg_values: &[Value], args_empty: bool, + ) -> Result { + use super::eval_option_methods::{is_option_receiver, is_unknown_method_error}; + let base_method = method.split("::").next().unwrap_or(method); + if is_option_receiver(receiver) { + if let Some(result) = self.eval_option_method(receiver, base_method, arg_values) { + return result; + } + } + let result = self.dispatch_native_method_call(receiver, method, arg_values, args_empty); + match result { + Err(ref e) if is_unknown_method_error(e, base_method) => self + .eval_option_method(receiver, base_method, arg_values) + .unwrap_or(result), + _ => result, + } + } + + /// OPTMETHODS-1: `Option`/`Result` method with closure arguments called + /// through the interpreter. `None` if `method` is not one of them. + fn eval_option_method( + &mut self, + receiver: &Value, + method: &str, + arg_values: &[Value], + ) -> super::eval_option_methods::OptionMethodResult { + let mut call = |f: &Value, args: &[Value]| self.call_function(f.clone(), args); + super::eval_option_methods::eval_option_method(receiver, method, arg_values, &mut call) + } + + /// Method tables of the receiver's own type (no `Option` bridge). + fn dispatch_native_method_call( + &mut self, + receiver: &Value, + method: &str, + arg_values: &[Value], + args_empty: bool, ) -> Result { // EVALUATOR-001: Strip turbofish syntax from method names // Example: "parse::" becomes "parse" @@ -300,100 +460,10 @@ impl Interpreter { self.eval_dataframe_method(columns, base_method, arg_values) } Value::Object(obj) => { - // Check if this is an actor instance - if let Some(Value::String(actor_name)) = obj.get("__actor") { - self.eval_actor_instance_method( - obj, - actor_name.as_ref(), - base_method, - arg_values, - ) - } - // Check if this is a class instance - else if let Some(Value::String(class_name)) = obj.get("__class") { - self.eval_class_instance_method( - obj, - class_name.as_ref(), - base_method, - arg_values, - ) - } - // Check if this is a struct instance with impl methods - else if let Some(Value::String(struct_name)) = - obj.get("__struct_type").or_else(|| obj.get("__struct")) - { - self.eval_struct_instance_method( - obj, - struct_name.as_ref(), - base_method, - arg_values, - ) - } - // Check if this is a `DataFrame` builder - else if let Some(Value::String(type_str)) = obj.get("__type") { - if type_str.as_ref() == "DataFrameBuilder" { - self.eval_dataframe_builder_method(obj, base_method, arg_values) - } else { - self.eval_object_method(obj, base_method, arg_values, args_empty) - } - } else { - self.eval_object_method(obj, base_method, arg_values, args_empty) - } + self.dispatch_plain_object_method(obj, base_method, arg_values, args_empty) } Value::ObjectMut(cell_rc) => { - // Dispatch mutable objects the same way as immutable ones - // Safe borrow: We only read metadata fields to determine dispatch - let obj = cell_rc - .lock() - .expect("Mutex poisoned: object lock is corrupted"); - - // Check if this is an actor instance - if let Some(Value::String(actor_name)) = obj.get("__actor") { - let actor_name = actor_name.clone(); - drop(obj); // Release borrow before recursive call - self.eval_actor_instance_method_mut( - cell_rc, - actor_name.as_ref(), - base_method, - arg_values, - ) - } - // Check if this is a class instance - else if let Some(Value::String(class_name)) = obj.get("__class") { - let class_name = class_name.clone(); - drop(obj); // Release borrow before recursive call - self.eval_class_instance_method_mut( - cell_rc, - class_name.as_ref(), - base_method, - arg_values, - ) - } - // Check if this is a struct instance with impl methods - else if let Some(Value::String(struct_name)) = - obj.get("__struct_type").or_else(|| obj.get("__struct")) - { - let struct_name = struct_name.clone(); - drop(obj); // Release borrow before recursive call - self.eval_struct_instance_method_mut( - cell_rc, - struct_name.as_ref(), - base_method, - arg_values, - ) - } - // ISSUE-116: Check if this is a File object - else if let Some(Value::String(type_name)) = obj.get("__type") { - if type_name.as_ref() == "File" { - drop(obj); // Release borrow before recursive call - return self.eval_file_method_mut(cell_rc, base_method, arg_values); - } - drop(obj); // Release borrow before recursive call - self.eval_object_method_mut(cell_rc, base_method, arg_values, args_empty) - } else { - drop(obj); // Release borrow before recursive call - self.eval_object_method_mut(cell_rc, base_method, arg_values, args_empty) - } + self.dispatch_object_mut_method(cell_rc, base_method, arg_values, args_empty) } Value::Struct { name, fields } => { // Dispatch struct instance method call @@ -413,6 +483,9 @@ impl Interpreter { arg_values, ) } + Value::EnumVariant { enum_name, .. } => self + .eval_enum_instance_method(receiver, enum_name, base_method, arg_values) + .unwrap_or_else(|| self.eval_generic_method(receiver, base_method, args_empty)), #[cfg(not(target_arch = "wasm32"))] Value::HtmlDocument(doc) => { self.eval_html_document_method(doc, base_method, arg_values) @@ -425,6 +498,67 @@ impl Interpreter { } } + /// Dispatch a method on an immutable object by its dispatch marker. + /// + /// Complexity: 5 + fn dispatch_plain_object_method( + &mut self, + obj: &std::collections::HashMap, + method: &str, + arg_values: &[Value], + args_empty: bool, + ) -> Result { + match ObjectKind::of(obj) { + ObjectKind::Actor(name) => { + self.eval_actor_instance_method(obj, &name, method, arg_values) + } + ObjectKind::Class(name) => { + self.eval_class_instance_method(obj, &name, method, arg_values) + } + ObjectKind::Struct(name) => { + self.eval_struct_instance_method(obj, &name, method, arg_values) + } + ObjectKind::Typed(name) if name == "DataFrameBuilder" => { + self.eval_dataframe_builder_method(obj, method, arg_values) + } + _ => self.eval_object_method(obj, method, arg_values, args_empty), + } + } + + /// Dispatch a method on a mutable object by its dispatch marker. The lock + /// is released before the handler runs. + /// + /// Complexity: 5 + fn dispatch_object_mut_method( + &mut self, + cell_rc: &std::sync::Arc>>, + method: &str, + arg_values: &[Value], + args_empty: bool, + ) -> Result { + let kind = ObjectKind::of( + &cell_rc + .lock() + .expect("Mutex poisoned: object lock is corrupted"), + ); + match kind { + ObjectKind::Actor(name) => { + self.eval_actor_instance_method_mut(cell_rc, &name, method, arg_values) + } + ObjectKind::Class(name) => { + self.eval_class_instance_method_mut(cell_rc, &name, method, arg_values) + } + ObjectKind::Struct(name) => { + self.eval_struct_instance_method_mut(cell_rc, &name, method, arg_values) + } + // ISSUE-116: File objects + ObjectKind::Typed(name) if name == "File" => { + self.eval_file_method_mut(cell_rc, method, arg_values) + } + _ => self.eval_object_method_mut(cell_rc, method, arg_values, args_empty), + } + } + pub(crate) fn eval_float_method( &self, f: f64, diff --git a/src/runtime/interpreter_misc_eval.rs b/src/runtime/interpreter_misc_eval.rs index 6a41fee4d..75e29ac0e 100644 --- a/src/runtime/interpreter_misc_eval.rs +++ b/src/runtime/interpreter_misc_eval.rs @@ -212,24 +212,28 @@ fn eval_import(interp: &mut Interpreter, module: &str) -> Result Result { if name == "vec" { - eval_vec_macro(interp, args) - } else if name == "println" { - eval_println_macro(interp, args) - } else if name == "format" { - eval_format_macro(interp, args) - } else { - Err(InterpreterError::RuntimeError(format!( + return eval_vec_macro(interp, args); + } + match crate::runtime::interpreter_format_calls::FormatSink::for_name(name) { + Some(crate::runtime::interpreter_format_calls::FormatSink::Return) if args.is_empty() => { + Err(InterpreterError::RuntimeError( + "format!() requires at least one argument".to_string(), + )) + } + Some(sink) => interp.eval_format_call(sink, args), + None => Err(InterpreterError::RuntimeError(format!( "Macro '{}!' not yet implemented", name - ))) + ))), } } @@ -243,118 +247,6 @@ fn eval_vec_macro(interp: &mut Interpreter, args: &[Expr]) -> Result Result { - if args.is_empty() { - println!(); - } else if args.len() == 1 { - // Single argument: print directly - let value = interp.eval_expr(&args[0])?; - println!("{}", value); - } else { - // Multiple arguments: use format! logic (Issue #82, #83) - let format_val = interp.eval_expr(&args[0])?; - let format_str = match format_val { - Value::String(ref s) => s.as_ref().to_string(), - _ => format_val.to_string(), - }; - - let mut values = Vec::new(); - for arg in &args[1..] { - values.push(interp.eval_expr(arg)?); - } - - // Use helper for format string replacement - let result = Interpreter::format_string_with_values(&format_str, &values); - println!("{}", result); - } - Ok(Value::Nil) -} - -/// format!() macro: Format string with placeholders (Issue #83) -fn eval_format_macro(interp: &mut Interpreter, args: &[Expr]) -> Result { - if args.is_empty() { - return Err(InterpreterError::RuntimeError( - "format!() requires at least one argument".to_string(), - )); - } - - // Evaluate format string - let format_val = interp.eval_expr(&args[0])?; - let format_str = format_val.to_string(); - - // Evaluate remaining arguments - let mut values = Vec::new(); - for arg in &args[1..] { - values.push(interp.eval_expr(arg)?); - } - - // Replace {} and {:?} placeholders with values - let result = format_with_placeholders(&format_str, &values); - Ok(Value::from_string(result)) -} - -/// Replace `{}` and `{:?}` placeholders in a format string with values. -/// Shared helper used by format!() macro evaluation. -fn try_consume_debug_placeholder( - chars: &mut std::iter::Peekable>, - result: &mut String, - values: &[Value], - value_index: &mut usize, -) -> bool { - if chars.peek() != Some(&':') { - return false; - } - chars.next(); - if chars.peek() != Some(&'?') { - result.push_str("{:"); - return true; - } - chars.next(); - if chars.peek() != Some(&'}') { - result.push_str("{:?"); - return true; - } - chars.next(); - if *value_index < values.len() { - result.push_str(&format!("{:?}", values[*value_index])); - *value_index += 1; - } else { - result.push_str("{:?}"); - } - true -} - -fn format_with_placeholders(format_str: &str, values: &[Value]) -> String { - let mut result = String::new(); - let mut chars = format_str.chars().peekable(); - let mut value_index = 0; - - while let Some(ch) = chars.next() { - if ch != '{' { - result.push(ch); - continue; - } - if try_consume_debug_placeholder(&mut chars, &mut result, values, &mut value_index) { - continue; - } - if chars.peek() == Some(&'}') { - chars.next(); - if value_index < values.len() { - result.push_str(&values[value_index].to_string()); - value_index += 1; - } else { - result.push_str("{}"); - } - } else { - result.push(ch); - } - } - - result -} - // ============================================================================ // Try operator (?) // ============================================================================ diff --git a/src/runtime/interpreter_tests/interpreter_tests_part10.rs b/src/runtime/interpreter_tests/interpreter_tests_part10.rs index 35172253d..3f71cfa87 100644 --- a/src/runtime/interpreter_tests/interpreter_tests_part10.rs +++ b/src/runtime/interpreter_tests/interpreter_tests_part10.rs @@ -849,7 +849,8 @@ fn test_format_macro_excess_placeholders() { let mut interp = Interpreter::new(); // More placeholders than values let result = interp.eval_string(r#"format!("{} {} {}", 1)"#); - assert!(result.is_ok()); // Should preserve extra placeholders + // FMTSPEC-1: rustc rejects missing arguments; so does the interpreter. + assert!(result.is_err()); } #[test] diff --git a/src/runtime/interpreter_tests/interpreter_tests_part6.rs b/src/runtime/interpreter_tests/interpreter_tests_part6.rs index 124ecab00..904c8daa0 100644 --- a/src/runtime/interpreter_tests/interpreter_tests_part6.rs +++ b/src/runtime/interpreter_tests/interpreter_tests_part6.rs @@ -1006,12 +1006,10 @@ fn test_format_macro_missing_values() { trailing_comment: None, contracts: Vec::new(), }; - let result = interp.eval_expr(¯o_expr).expect("should evaluate"); - if let Value::String(s) = result { - assert!(s.contains("{}")); - } else { - panic!("Expected string"); - } + // FMTSPEC-1: a field with no argument is an error (rustc rejects it), + // not the placeholder text. + let err = interp.eval_expr(¯o_expr).unwrap_err(); + assert!(err.to_string().contains("positional argument"), "{err}"); } // ============== Lookup Variable Special Cases ============== diff --git a/src/runtime/interpreter_tests/interpreter_tests_part9.rs b/src/runtime/interpreter_tests/interpreter_tests_part9.rs index b40939b66..a4a252941 100644 --- a/src/runtime/interpreter_tests/interpreter_tests_part9.rs +++ b/src/runtime/interpreter_tests/interpreter_tests_part9.rs @@ -432,7 +432,7 @@ fn test_binary_op_gt() { #[test] fn test_format_string_with_values() { - let result = Interpreter::format_string_with_values( + let result = crate::runtime::value_format::format_string_with_values( "x={}, y={}", &[Value::Integer(10), Value::Integer(20)], ); diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs index 9e02ef93b..530ab1057 100644 --- a/src/runtime/mod.rs +++ b/src/runtime/mod.rs @@ -107,14 +107,17 @@ pub mod eval_loops; pub mod eval_method; pub mod eval_method_dispatch; pub mod eval_operations; +pub mod eval_option_methods; // OPTMETHODS-1: Option/Result methods pub mod eval_pattern; pub mod eval_pattern_match; pub mod eval_string; pub mod eval_string_interpolation; pub mod eval_string_methods; pub mod eval_try_catch; // EXTREME TDD: Try/catch error handling +pub mod fmt_spec; // FMTSPEC-1: Rust std::fmt format strings pub mod gc; pub mod gc_impl; // EXTREME TDD: Full GC implementation with tests +pub mod interpreter_format_calls; // FMTSPEC-1: println/print/eprintln/eprint/format pub mod type_feedback; // EXTREME TDD: JIT type feedback system (extracted from interpreter.rs) pub mod validation; pub mod value_format; // EXTREME TDD: Value formatting utilities (extracted from interpreter.rs) diff --git a/src/runtime/value_format.rs b/src/runtime/value_format.rs index 86930dc31..dda85b358 100644 --- a/src/runtime/value_format.rs +++ b/src/runtime/value_format.rs @@ -20,7 +20,7 @@ pub fn format_string_with_values(format_str: &str, values: &[Value]) -> String { if chars.peek() == Some(&'}') { chars.next(); if value_index < values.len() { - result.push_str(&format!("{:?}", values[value_index])); + result.push_str(&rust_debug(&values[value_index])); value_index += 1; } else { result.push_str("{:?}"); @@ -55,28 +55,121 @@ pub fn format_string_with_values(format_str: &str, values: &[Value]) -> String { result } -/// Format a value with a format specifier like `:.2` for floats +/// Format a value with a format specifier like `:.2` (f-string `{x:.2}`). +/// FMTSPEC-1: the spec is parsed by [`crate::runtime::fmt_spec`], the one +/// format-spec parser; a spec it refuses leaves the value's plain text. pub fn format_value_with_spec(value: &Value, spec: &str) -> String { - // Parse format specifier (e.g., ":.2" -> precision 2) - if let Some(stripped) = spec.strip_prefix(":.") { - if let Ok(precision) = stripped.parse::() { - match value { - Value::Float(f) => return format!("{f:.precision$}"), - Value::Integer(i) => { - let f = *i as f64; - return format!("{f:.precision$}"); - } - _ => {} - } - } - } - // Default formatting if spec doesn't match or isn't supported - value.to_string() + let text = spec.strip_prefix(':').unwrap_or(spec); + crate::runtime::fmt_spec::parse_spec(text) + .and_then(|spec| crate::runtime::fmt_spec::format_value(value, &spec)) + .unwrap_or_else(|_| value.to_string()) } -/// Format value for debug output +/// Format value for debug output (`{:?}`), as rustc prints the transpiled value. pub fn format_value_debug(value: &Value) -> String { - format!("{value:?}") + rust_debug(value) +} + +/// RHLGA-1: render a value the way Rust's `{:?}` renders the transpiled value +/// (`[1, 2]`, `"hi"`, `Some(3)`, `(1, true)`, `2.0`), not the internal enum. +/// Struct/object field order is not recorded in a `Value`; keys are sorted. +pub fn rust_debug(value: &Value) -> String { + debug_scalar(value).unwrap_or_else(|| debug_compound(value)) +} + +fn debug_scalar(value: &Value) -> Option { + match value { + Value::Integer(i) => Some(i.to_string()), + Value::Float(f) => Some(format!("{f:?}")), + Value::Bool(b) => Some(b.to_string()), + Value::Byte(b) => Some(b.to_string()), + Value::Nil => Some("()".to_string()), + Value::String(s) => Some(format!("{:?}", s.as_ref())), + _ => None, + } +} + +fn debug_compound(value: &Value) -> String { + match value { + Value::Array(items) => format!("[{}]", debug_list(items)), + Value::Tuple(items) => debug_tuple(items), + Value::EnumVariant { + variant_name, data, .. + } => debug_variant(variant_name, data.as_deref()), + Value::Struct { name, fields } => debug_named_fields(name, fields), + Value::Object(map) => debug_object_map(map), + Value::ObjectMut(cell) => cell + .lock() + .map_or_else(|_| value.to_string(), |map| debug_object_map(&map)), + Value::Class { + class_name, fields, .. + } => fields.read().map_or_else( + |_| value.to_string(), + |map| debug_named_fields(class_name, &map), + ), + Value::Range { + start, + end, + inclusive, + } => debug_range(start, end, *inclusive), + _ => value.to_string(), + } +} + +fn debug_list(items: &[Value]) -> String { + items.iter().map(rust_debug).collect::>().join(", ") +} + +fn debug_tuple(items: &[Value]) -> String { + if items.len() == 1 { + format!("({},)", rust_debug(&items[0])) + } else { + format!("({})", debug_list(items)) + } +} + +fn debug_variant(name: &str, data: Option<&[Value]>) -> String { + match data { + Some(values) if !values.is_empty() => format!("{name}({})", debug_list(values)), + _ => name.to_string(), + } +} + +fn debug_range(start: &Value, end: &Value, inclusive: bool) -> String { + let op = if inclusive { "..=" } else { ".." }; + format!("{}{op}{}", rust_debug(start), rust_debug(end)) +} + +fn sorted_entries(map: &std::collections::HashMap) -> Vec<(&String, &Value)> { + let mut entries: Vec<_> = map.iter().filter(|(k, _)| !k.starts_with("__")).collect(); + entries.sort_by(|a, b| a.0.cmp(b.0)); + entries +} + +/// `Name { a: 1, b: 2 }` (derived Debug); a field-less struct prints `Name`. +fn debug_named_fields(name: &str, map: &std::collections::HashMap) -> String { + let fields: Vec = sorted_entries(map) + .into_iter() + .map(|(k, v)| format!("{k}: {}", rust_debug(v))) + .collect(); + if fields.is_empty() { + name.to_string() + } else { + format!("{name} {{ {} }}", fields.join(", ")) + } +} + +/// An object literal transpiles to a `BTreeMap`: `{"a": 1}`. An instance +/// carrying `__class` prints as its struct. +fn debug_object_map(map: &std::collections::HashMap) -> String { + if let Some(Value::String(class)) = map.get("__class") { + return debug_named_fields(class, map); + } + let entries: Vec = sorted_entries(map) + .into_iter() + .map(|(k, v)| format!("{k:?}: {}", rust_debug(v))) + .collect(); + format!("{{{}}}", entries.join(", ")) } /// Format value for display output (no extra quotes for strings) @@ -114,7 +207,7 @@ mod tests { #[test] fn test_format_debug_placeholder() { let result = format_string_with_values("{:?}", &[Value::from_string("test".to_string())]); - assert!(result.contains("String")); + assert_eq!(result, "\"test\""); } #[test] @@ -199,8 +292,9 @@ mod tests { #[test] fn test_spec_integer_as_float() { + // FMTSPEC-1: Rust ignores precision for integers let result = format_value_with_spec(&Value::Integer(42), ":.2"); - assert_eq!(result, "42.00"); + assert_eq!(result, format!("{:.2}", 42)); } #[test] @@ -224,13 +318,14 @@ mod tests { #[test] fn test_spec_non_numeric_value() { let result = format_value_with_spec(&Value::from_string("hello".to_string()), ":.2"); - assert_eq!(result, "\"hello\""); + // FMTSPEC-1: precision truncates a string, as Rust does + assert_eq!(result, format!("{:.2}", "hello")); } #[test] fn test_spec_bool_value() { let result = format_value_with_spec(&Value::Bool(true), ":.2"); - assert_eq!(result, "true"); + assert_eq!(result, format!("{:.2}", true)); } #[test] @@ -243,13 +338,13 @@ mod tests { #[test] fn test_debug_integer() { let result = format_value_debug(&Value::Integer(42)); - assert!(result.contains("42")); + assert_eq!(result, "42"); } #[test] fn test_debug_string() { - let result = format_value_debug(&Value::from_string("test".to_string())); - assert!(result.contains("String")); + let result = format_value_debug(&Value::from_string("a\"b".to_string())); + assert_eq!(result, "\"a\\\"b\""); } #[test] @@ -258,7 +353,7 @@ mod tests { vec![Value::Integer(1), Value::Integer(2)].into_boxed_slice(), )); let result = format_value_debug(&arr); - assert!(result.contains("Array")); + assert_eq!(result, "[1, 2]"); } // format_value_display tests @@ -340,20 +435,19 @@ mod tests { #[test] fn test_format_debug_nil() { let result = format_value_debug(&Value::Nil); - assert!(result.contains("Nil")); + assert_eq!(result, "()"); } #[test] fn test_format_debug_bool() { let result = format_value_debug(&Value::Bool(false)); - assert!(result.contains("Bool")); - assert!(result.contains("false")); + assert_eq!(result, "false"); } #[test] fn test_format_debug_float() { - let result = format_value_debug(&Value::Float(2.718)); - assert!(result.contains("Float")); + assert_eq!(format_value_debug(&Value::Float(2.718)), "2.718"); + assert_eq!(format_value_debug(&Value::Float(2.0)), "2.0"); } #[test] @@ -366,7 +460,7 @@ mod tests { #[test] fn test_format_consecutive_debug_placeholders() { let result = format_string_with_values("{:?}{:?}", &[Value::Integer(1), Value::Integer(2)]); - assert!(result.contains("Integer")); + assert_eq!(result, "12"); } #[test] @@ -400,7 +494,7 @@ mod tests { #[test] fn test_spec_integer_precision_3() { let result = format_value_with_spec(&Value::Integer(100), ":.3"); - assert_eq!(result, "100.000"); + assert_eq!(result, format!("{:.3}", 100)); } #[test] @@ -491,7 +585,7 @@ mod tests { fn test_format_debug_tuple() { let tuple = Value::Tuple(Arc::from(vec![Value::Integer(1)])); let result = format_value_debug(&tuple); - assert!(result.contains("Tuple")); + assert_eq!(result, "(1,)"); } #[test] @@ -507,8 +601,46 @@ mod tests { "{:?} {:?} {:?}", &[Value::Integer(1), Value::Float(2.0), Value::Bool(true)], ); - assert!(result.contains("Integer")); - assert!(result.contains("Float")); - assert!(result.contains("Bool")); + assert_eq!(result, "1 2.0 true"); + } + + #[test] + fn test_rust_debug_compound_values() { + let some = Value::EnumVariant { + enum_name: "Option".to_string(), + variant_name: "Some".to_string(), + data: Some(vec![Value::Integer(3)]), + }; + let none = Value::EnumVariant { + enum_name: "Option".to_string(), + variant_name: "None".to_string(), + data: None, + }; + assert_eq!(rust_debug(&some), "Some(3)"); + assert_eq!(rust_debug(&none), "None"); + let pair = Value::Tuple(Arc::from(vec![Value::Integer(1), Value::Bool(true)])); + assert_eq!(rust_debug(&pair), "(1, true)"); + let range = Value::Range { + start: Box::new(Value::Integer(0)), + end: Box::new(Value::Integer(3)), + inclusive: true, + }; + assert_eq!(rust_debug(&range), "0..=3"); + } + + #[test] + fn test_rust_debug_struct_and_object() { + let mut fields = std::collections::HashMap::new(); + fields.insert("y".to_string(), Value::Integer(2)); + fields.insert("x".to_string(), Value::from_string("s".to_string())); + let point = Value::Struct { + name: "P".to_string(), + fields: Arc::new(fields.clone()), + }; + assert_eq!(rust_debug(&point), "P { x: \"s\", y: 2 }"); + assert_eq!( + rust_debug(&Value::Object(Arc::new(fields))), + "{\"x\": \"s\", \"y\": 2}" + ); } } diff --git a/src/stdlib/hash.rs b/src/stdlib/hash.rs new file mode 100644 index 000000000..50811b365 --- /dev/null +++ b/src/stdlib/hash.rs @@ -0,0 +1,212 @@ +//! MD5 message digest (RFC 1321) behind the `compute_hash()` builtin (STDLIB-005). +//! +//! `compute_hash(path)` is a content fingerprint for duplicate detection, specified +//! as 32 lowercase hex digits of MD5. It is not a security primitive. The digest is +//! implemented in-tree so the builtin keeps that contract without an extra crate. + +/// Per-step left-rotation amounts (RFC 1321 §3.4). +const SHIFTS: [u32; 64] = [ + 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, // + 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, // + 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, // + 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, +]; + +/// Per-step additive constants `floor(|sin(i + 1)| * 2^32)` (RFC 1321 §3.4). +const K: [u32; 64] = [ + 0xd76a_a478, + 0xe8c7_b756, + 0x2420_70db, + 0xc1bd_ceee, + 0xf57c_0faf, + 0x4787_c62a, + 0xa830_4613, + 0xfd46_9501, + 0x6980_98d8, + 0x8b44_f7af, + 0xffff_5bb1, + 0x895c_d7be, + 0x6b90_1122, + 0xfd98_7193, + 0xa679_438e, + 0x49b4_0821, + 0xf61e_2562, + 0xc040_b340, + 0x265e_5a51, + 0xe9b6_c7aa, + 0xd62f_105d, + 0x0244_1453, + 0xd8a1_e681, + 0xe7d3_fbc8, + 0x21e1_cde6, + 0xc337_07d6, + 0xf4d5_0d87, + 0x455a_14ed, + 0xa9e3_e905, + 0xfcef_a3f8, + 0x676f_02d9, + 0x8d2a_4c8a, + 0xfffa_3942, + 0x8771_f681, + 0x6d9d_6122, + 0xfde5_380c, + 0xa4be_ea44, + 0x4bde_cfa9, + 0xf6bb_4b60, + 0xbebf_bc70, + 0x289b_7ec6, + 0xeaa1_27fa, + 0xd4ef_3085, + 0x0488_1d05, + 0xd9d4_d039, + 0xe6db_99e5, + 0x1fa2_7cf8, + 0xc4ac_5665, + 0xf429_2244, + 0x432a_ff97, + 0xab94_23a7, + 0xfc93_a039, + 0x655b_59c3, + 0x8f0c_cc92, + 0xffef_f47d, + 0x8584_5dd1, + 0x6fa8_7e4f, + 0xfe2c_e6e0, + 0xa301_4314, + 0x4e08_11a1, + 0xf753_7e82, + 0xbd3a_f235, + 0x2ad7_d2bb, + 0xeb86_d391, +]; + +/// Initial chaining values A, B, C, D (RFC 1321 §3.3). +const INIT: [u32; 4] = [0x6745_2301, 0xefcd_ab89, 0x98ba_dcfe, 0x1032_5476]; + +/// MD5 digest of `data` as 32 lowercase hex digits. +/// +/// # Examples +/// +/// ``` +/// assert_eq!( +/// ruchy::stdlib::hash::md5_hex(b"hello world"), +/// "5eb63bbbe01eeed093cb22bb8f5acdc3" +/// ); +/// ``` +#[must_use] +pub fn md5_hex(data: &[u8]) -> String { + md5_digest(data) + .iter() + .map(|b| format!("{b:02x}")) + .collect() +} + +/// Raw 16-byte MD5 digest of `data`. +#[must_use] +pub fn md5_digest(data: &[u8]) -> [u8; 16] { + let mut state = INIT; + for block in pad(data).chunks_exact(64) { + compress(&mut state, block); + } + let mut out = [0u8; 16]; + for (chunk, word) in out.chunks_exact_mut(4).zip(state) { + chunk.copy_from_slice(&word.to_le_bytes()); + } + out +} + +/// Append the 0x80 marker, zero fill to 56 mod 64, and the little-endian bit length. +fn pad(data: &[u8]) -> Vec { + let bit_len = (data.len() as u64).wrapping_mul(8); + let mut msg = Vec::with_capacity(data.len() + 72); + msg.extend_from_slice(data); + msg.push(0x80); + while msg.len() % 64 != 56 { + msg.push(0); + } + msg.extend_from_slice(&bit_len.to_le_bytes()); + msg +} + +/// Round function value and message-word index for step `i` (RFC 1321 §3.4). +fn step_fn(i: usize, b: u32, c: u32, d: u32) -> (u32, usize) { + match i / 16 { + 0 => ((b & c) | (!b & d), i), + 1 => ((d & b) | (!d & c), (5 * i + 1) % 16), + 2 => (b ^ c ^ d, (3 * i + 5) % 16), + _ => (c ^ (b | !d), (7 * i) % 16), + } +} + +/// Fold one 64-byte block into the chaining state. +fn compress(state: &mut [u32; 4], block: &[u8]) { + let mut words = [0u32; 16]; + for (word, bytes) in words.iter_mut().zip(block.chunks_exact(4)) { + *word = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]); + } + let [mut a, mut b, mut c, mut d] = *state; + for i in 0..64 { + let (f, g) = step_fn(i, b, c, d); + let sum = a.wrapping_add(f).wrapping_add(K[i]).wrapping_add(words[g]); + a = d; + d = c; + c = b; + b = b.wrapping_add(sum.rotate_left(SHIFTS[i])); + } + for (s, v) in state.iter_mut().zip([a, b, c, d]) { + *s = s.wrapping_add(v); + } +} + +#[cfg(test)] +mod tests { + use super::md5_hex; + + /// RFC 1321 Appendix A.5 test suite. + #[test] + fn test_stdlib_005_md5_rfc1321_suite() { + let cases: [(&[u8], &str); 7] = [ + (b"", "d41d8cd98f00b204e9800998ecf8427e"), + (b"a", "0cc175b9c0f1b6a831c399e269772661"), + (b"abc", "900150983cd24fb0d6963f7d28e17f72"), + (b"message digest", "f96b697d7cb7938d525a2f31aaf161d0"), + ( + b"abcdefghijklmnopqrstuvwxyz", + "c3fcd3d76192e4007dfb496cca67e13b", + ), + ( + b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789", + "d174ab98d277d9f5a5611c2c9f419d9f", + ), + ( + b"12345678901234567890123456789012345678901234567890123456789012345678901234567890", + "57edf4a22be3c955ac49da2e2107b67a", + ), + ]; + for (input, expected) in cases { + assert_eq!(md5_hex(input), expected, "input {input:?}"); + } + } + + /// Padding boundaries: 55 bytes fits one block, 56 and 64 need a second. + #[test] + fn test_stdlib_005_md5_padding_boundaries() { + assert_eq!(md5_hex(&[b'a'; 55]), "ef1772b6dff9a122358552954ad0df65"); + assert_eq!(md5_hex(&[b'a'; 56]), "3b0c8ac703f828b04c6c197006d17218"); + assert_eq!(md5_hex(&[b'a'; 64]), "014842d480b571495a4a0363793f7367"); + } + + mod properties { + use super::super::md5_hex; + use proptest::prelude::*; + + proptest! { + #[test] + fn prop_stdlib_005_md5_is_32_lower_hex(data in proptest::collection::vec(any::(), 0..300)) { + let hex = md5_hex(&data); + prop_assert_eq!(hex.len(), 32); + prop_assert!(hex.chars().all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c))); + } + } + } +} diff --git a/src/stdlib/mod.rs b/src/stdlib/mod.rs index 7578a5eb8..19977fea3 100644 --- a/src/stdlib/mod.rs +++ b/src/stdlib/mod.rs @@ -58,6 +58,7 @@ pub mod simular_bridge; // Pillar 7: Simulation Engine // Pillar 4: Shell Transp // === Utility Modules === pub mod env; pub mod fs; +pub mod hash; pub mod json; pub mod logging; pub mod path; diff --git a/tests/arraymut_1_in_place.rs b/tests/arraymut_1_in_place.rs new file mode 100644 index 000000000..0a65cb092 --- /dev/null +++ b/tests/arraymut_1_in_place.rs @@ -0,0 +1,311 @@ +//! ARRAYMUT-1: the in-place `Vec` methods (`sort`, `reverse`, `append`, +//! `insert`, `remove`, `clear`, `truncate`, `dedup`, `extend`) change their +//! receiver when it is a local array or a struct field, and return what Rust +//! returns: `()` (printed as `nil`) for all of them except `remove`, which +//! returns the removed element. +//! +//! Before the fix `v.sort()` / `v.reverse()` returned a changed copy and left +//! `v` untouched, and `insert`/`remove`/`clear`/`truncate`/`dedup` failed with +//! "Unknown array method". `sorted`/`reversed` stay functional. +//! +//! `extend(…)` is not exercised here: `.extend(` does not parse yet (tracked +//! as EXTENDKW-1); its interpreter behaviour is covered by lib unit tests in +//! `src/runtime/eval_array.rs`. + +use assert_cmd::Command; +use std::path::Path; +use std::time::Duration; + +fn ruchy() -> Command { + let mut cmd = Command::cargo_bin("ruchy").expect("ruchy binary is built for tests"); + cmd.timeout(Duration::from_secs(10)); + cmd +} + +fn write_source(dir: &Path, src: &str) -> std::path::PathBuf { + let file = dir.join("prog.ruchy"); + std::fs::write(&file, src).expect("write ruchy source"); + file +} + +fn output_of(mut cmd: Command) -> std::process::Output { + cmd.output().expect("spawn command") +} + +fn stdout_of(cmd: Command, what: &str) -> String { + let out = output_of(cmd); + assert!( + out.status.success(), + "{what} failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8(out.stdout).expect("utf8 stdout") +} + +fn interpret(dir: &Path, src: &str) -> String { + let file = write_source(dir, src); + let mut cmd = ruchy(); + cmd.arg("run").arg(file); + stdout_of(cmd, "ruchy run") +} + +fn run_file(src: &str) -> String { + let dir = tempfile::tempdir().expect("tempdir"); + interpret(dir.path(), src) +} + +/// Run a program that must fail; returns its combined stdout+stderr. +fn run_failing(src: &str) -> String { + let dir = tempfile::tempdir().expect("tempdir"); + let file = write_source(dir.path(), src); + let mut cmd = ruchy(); + cmd.arg("run").arg(file); + let out = output_of(cmd); + assert!(!out.status.success(), "expected failure for:\n{src}"); + format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ) +} + +fn transpile(dir: &Path, src: &str) -> String { + let file = write_source(dir, src); + let mut cmd = ruchy(); + cmd.arg("transpile").arg(file); + stdout_of(cmd, "ruchy transpile") +} + +fn rustc_run(dir: &Path, rust: &str) -> String { + let main_rs = dir.join("main.rs"); + let bin = dir.join("prog_bin"); + std::fs::write(&main_rs, rust).expect("write main.rs"); + let mut rustc = Command::new("rustc"); + rustc + .args(["--edition", "2021", "-A", "warnings", "-o"]) + .arg(&bin) + .arg(&main_rs) + .timeout(Duration::from_secs(120)); + stdout_of(rustc, &format!("rustc on transpiled Rust:\n{rust}\n")); + let mut run = Command::new(&bin); + run.timeout(Duration::from_secs(30)); + stdout_of(run, "transpiled binary") +} + +/// Transpile, compile, run; assert interpreter == rustc output. +fn differential(src: &str) { + let dir = tempfile::tempdir().expect("tempdir"); + let rust = transpile(dir.path(), src); + let expected = interpret(dir.path(), src); + let actual = rustc_run(dir.path(), &rust); + assert_eq!( + actual, expected, + "transpiled Rust disagrees with the interpreter\nsource:\n{src}\nrust:\n{rust}" + ); +} + +/// `op` on a local `[3, 1, 2, 2]`: prints the call's result, then the array. +fn on_local(op: &str) -> String { + run_file(&format!( + "fun main() {{\n let mut v = [3, 1, 2, 2]\n println(v.{op})\n println(v)\n}}\n" + )) +} + +const BAG: &str = "struct Bag { items: Vec }\n"; + +/// `op` on `self.items` inside a `&mut self` method, then the field. +fn on_self_field(op: &str) -> String { + run_file(&format!( + "{BAG}impl Bag {{ + fun apply(&mut self) {{ + println(self.items.{op}) + }} +}} +fun main() {{ + let mut b = Bag {{ items: vec![3, 1, 2, 2] }} + b.apply() + println(b.items) +}} +" + )) +} + +/// Same op on a local and on `self.items`: both must give `expected`. +fn assert_in_place(op: &str, expected: &str) { + assert_eq!(on_local(op), expected, "local receiver, `{op}`"); + assert_eq!(on_self_field(op), expected, "self.field receiver, `{op}`"); +} + +#[test] +fn test_arraymut_1_01_sort_sorts_receiver() { + assert_in_place("sort()", "nil\n[1, 2, 2, 3]\n"); +} + +#[test] +fn test_arraymut_1_02_reverse_reverses_receiver() { + assert_in_place("reverse()", "nil\n[2, 2, 1, 3]\n"); +} + +#[test] +fn test_arraymut_1_03_insert_places_element() { + assert_in_place("insert(0, 9)", "nil\n[9, 3, 1, 2, 2]\n"); + assert_in_place("insert(4, 9)", "nil\n[3, 1, 2, 2, 9]\n"); +} + +#[test] +fn test_arraymut_1_04_remove_returns_removed_element() { + assert_in_place("remove(1)", "1\n[3, 2, 2]\n"); +} + +#[test] +fn test_arraymut_1_05_clear_empties_receiver() { + assert_in_place("clear()", "nil\n[]\n"); +} + +#[test] +fn test_arraymut_1_06_truncate_shortens_receiver() { + assert_in_place("truncate(2)", "nil\n[3, 1]\n"); + assert_in_place("truncate(10)", "nil\n[3, 1, 2, 2]\n"); +} + +#[test] +fn test_arraymut_1_07_dedup_removes_consecutive_duplicates() { + assert_in_place("dedup()", "nil\n[3, 1, 2]\n"); +} + +#[test] +fn test_arraymut_1_08_append_moves_other_elements_in() { + assert_in_place("append([7, 8])", "nil\n[3, 1, 2, 2, 7, 8]\n"); +} + +#[test] +fn test_arraymut_1_09_field_on_local_struct_is_written_back() { + let src = format!( + "{BAG}fun main() {{ + let mut b = Bag {{ items: vec![3, 1, 2] }} + b.items.sort() + b.items.insert(0, 0) + println(b.items.remove(3)) + println(b.items) +}} +" + ); + assert_eq!(run_file(&src), "3\n[0, 1, 2]\n"); +} + +#[test] +fn test_arraymut_1_10_out_of_range_insert_is_runtime_error() { + let src = "fun main() {\n let mut v = [1, 2]\n v.insert(3, 9)\n println(v)\n}\n"; + let out = run_failing(src); + assert!(out.contains("insert"), "error names the method: {out}"); +} + +#[test] +fn test_arraymut_1_11_out_of_range_remove_is_runtime_error() { + let src = "fun main() {\n let mut v = [1, 2]\n v.remove(2)\n println(v)\n}\n"; + let out = run_failing(src); + assert!(out.contains("remove"), "error names the method: {out}"); +} + +#[test] +fn test_arraymut_1_12_sorted_and_reversed_stay_functional() { + let src = "fun main() {\n let v = [3, 1, 2]\n println(v.sorted())\n println(v.reversed())\n println(v)\n}\n"; + assert_eq!(run_file(src), "[1, 2, 3]\n[2, 1, 3]\n[3, 1, 2]\n"); +} + +#[test] +fn test_arraymut_1_13_mutation_inside_loop_persists() { + let src = "fun main() {\n let mut v = [5, 4, 3, 2, 1]\n let mut i = 0\n while i < 2 {\n v.remove(0)\n i = i + 1\n }\n v.sort()\n println(v)\n}\n"; + assert_eq!(run_file(src), "[1, 2, 3]\n"); +} + +#[test] +fn test_arraymut_1_14_differential_local_vec() { + differential( + "fun main() { + let mut v = vec![3, 1, 2, 2] + v.sort() + v.dedup() + v.reverse() + v.insert(0, 9) + let r = v.remove(1) + v.truncate(3) + println(\"{} {:?}\", r, v) + v.clear() + println(\"{}\", v.len()) +} +", + ); +} + +#[test] +fn test_arraymut_1_15_differential_self_field() { + differential(&format!( + "{BAG}impl Bag {{ + fun tidy(&mut self) -> i32 {{ + self.items.sort() + self.items.dedup() + self.items.insert(0, 7) + self.items.truncate(3) + self.items.remove(0) + }} +}} +fun main() {{ + let mut b = Bag {{ items: vec![3, 1, 2, 2, 5] }} + let r = b.tidy() + println(\"{{}} {{:?}}\", r, b.items) +}} +" + )); +} + +// ---------- RHLGA-1 review F3/F4: `&mut self` inference ---------- + +/// Transpile, compile with rustc, run; the rustc output. +fn through_rustc(src: &str) -> String { + let dir = tempfile::tempdir().expect("tempdir"); + let rust = transpile(dir.path(), src); + rustc_run(dir.path(), &rust) +} + +#[test] +fn test_arraymut_1_16_resize_and_extend_from_slice_infer_mut_self() { + let src = "struct Bag { items: Vec } +impl Bag { + fun grow(self) { + self.items.resize(3, 0) + } + fun more(self) { + self.items.extend_from_slice(&[9]) + } +} +fun main() { + let mut b = Bag { items: vec![1] } + b.grow() + b.more() + println(b.items) +} +"; + assert_eq!(through_rustc(src), "[1, 0, 0, 9]\n"); +} + +#[test] +fn test_arraymut_1_17_mut_borrow_of_self_field_infers_mut_self() { + let src = "struct Bag { items: Vec } +fun add_one(xs: &mut Vec) -> usize { + xs.push(1) + xs.len() +} +impl Bag { + fun fill(self) -> usize { + add_one(&mut self.items) + } +} +fun main() { + let mut b = Bag { items: vec![7] } + println(b.fill()) + println(b.items) +} +"; + assert_eq!(through_rustc(src), "2\n[7, 1]\n"); +} diff --git a/tests/bug_032_range_function_not_transpiled.rs b/tests/bug_032_range_function_not_transpiled.rs index 5ef3d2b94..555a75cfc 100644 --- a/tests/bug_032_range_function_not_transpiled.rs +++ b/tests/bug_032_range_function_not_transpiled.rs @@ -126,13 +126,17 @@ fun main() { fs::write(&source, code).expect("Failed to write test file"); + let binary = temp.path().join("test_binary"); ruchy_cmd() .arg("compile") .arg(&source) .arg("-o") - .arg(temp.path().join("test_binary")) + .arg(&binary) .assert() .success(); + + // RHLGA-1: a range is already an iterator; the binary counts it. + Command::new(&binary).assert().success().stdout("10\n"); } /// Test 5: Multiple `range()` calls diff --git a/tests/cli_contract_sovereign.rs b/tests/cli_contract_sovereign.rs index 3312f8019..f1f5766df 100644 --- a/tests/cli_contract_sovereign.rs +++ b/tests/cli_contract_sovereign.rs @@ -91,6 +91,32 @@ fn test_infra_destroy_missing_file() { .failure(); } +/// G2B-S2: a path that is missing on every host (`/nonexistent` is a real +/// directory on some machines, so the test above also covers "not a file"). +#[test] +fn test_infra_destroy_path_missing_everywhere() { + let dir = TempDir::new().unwrap(); + ruchy_cmd() + .arg("infra") + .arg("destroy") + .arg(dir.path().join("absent.ruchy")) + .assert() + .failure() + .stderr(predicate::str::contains("File not found")); +} + +#[test] +fn test_infra_destroy_directory_is_not_a_spec() { + let dir = TempDir::new().unwrap(); + ruchy_cmd() + .arg("infra") + .arg("destroy") + .arg(dir.path()) + .assert() + .failure() + .stderr(predicate::str::contains("not a file")); +} + // ============================================================================ // ruchy sim // ============================================================================ @@ -511,8 +537,10 @@ fn test_contracts_check_with_threshold() { .arg("--min-coverage") .arg("80") .assert() - .success() - .stdout(predicate::str::contains("80.0%")); + // `fn main() {}` carries no contract: 0.0% < 80.0% must fail the gate. + .failure() + .stdout(predicate::str::contains("80.0%")) + .stderr(predicate::str::contains("below threshold 80.0%")); } // ============================================================================ @@ -558,5 +586,5 @@ fn test_version_is_5_0() { .arg("--version") .assert() .success() - .stdout(predicate::str::contains("5.0.0-alpha")); + .stdout(predicate::str::contains("ruchy 5.0.")); } diff --git a/tests/cli_interactive_validation.rs b/tests/cli_interactive_validation.rs index 5a47da2a7..6ca24fcd3 100644 --- a/tests/cli_interactive_validation.rs +++ b/tests/cli_interactive_validation.rs @@ -18,10 +18,9 @@ use tempfile::TempDir; // HELPER FUNCTIONS // ============================================================================ -/// Get path to ruchy binary +/// Get path to the cargo-built ruchy binary (honours `CARGO_TARGET_DIR`) fn ruchy_binary() -> String { - let manifest_dir = env!("CARGO_MANIFEST_DIR"); - format!("{manifest_dir}/target/debug/ruchy") + env!("CARGO_BIN_EXE_ruchy").to_string() } /// Create temp directory diff --git a/tests/dogfood_gates_declared.rs b/tests/dogfood_gates_declared.rs index 05bfbf87a..e3f0e87fa 100644 --- a/tests/dogfood_gates_declared.rs +++ b/tests/dogfood_gates_declared.rs @@ -15,13 +15,12 @@ use std::process::Command; /// Gates declared here but owned by another in-flight ticket. /// -/// `scripts/release-policy.sh` is written by PMAT-135 on its own branch. Until -/// that branch merges the file is absent from this worktree, and the -/// declared-but-missing rule would fire on it for a reason that is not a defect -/// of this tree. The allowlist is deliberately narrow: one name, and -/// `test_pmat_136_pending_allowlist_is_empty_once_the_gate_lands` deletes the +/// Empty: `scripts/release-policy.sh` (PMAT-135) has landed, so every declared +/// gate is again covered by the declared-but-missing rule. A future entry must +/// name a gate owned by an in-flight ticket; +/// `test_pmat_136_pending_allowlist_is_empty_once_the_gate_lands` removes the /// exemption the moment the file appears. -const PENDING_FROM_OTHER_TICKETS: [&str; 1] = ["scripts/release-policy.sh"]; +const PENDING_FROM_OTHER_TICKETS: [&str; 0] = []; /// The crate root, independent of the caller's working directory. fn repo_root() -> PathBuf { diff --git a/tests/enumfieldkw_1.rs b/tests/enumfieldkw_1.rs new file mode 100644 index 000000000..c5078354e --- /dev/null +++ b/tests/enumfieldkw_1.rs @@ -0,0 +1,168 @@ +//! ENUMFIELDKW-1: a keyword names a field of an enum struct variant, as it +//! names a struct field (RESFIELD-1) and a member after `.` (EXTENDKW-1). +//! +//! Before the fix `enum Test { Variant { enum: int } }` failed to parse with +//! "Expected RightBrace, found Enum" (found by the parser_defect_018 +//! proptest `prop_enum_variant_with_single_field`). +//! +//! Rust reserved words are emitted as raw identifiers (`r#enum`). + +use assert_cmd::Command; +use std::path::Path; +use std::time::Duration; + +fn ruchy() -> Command { + let mut cmd = Command::cargo_bin("ruchy").expect("ruchy binary is built for tests"); + cmd.timeout(Duration::from_secs(10)); + cmd +} + +fn write_source(dir: &Path, src: &str) -> std::path::PathBuf { + let file = dir.join("prog.ruchy"); + std::fs::write(&file, src).expect("write ruchy source"); + file +} + +fn stdout_of(mut cmd: Command, what: &str) -> String { + let out = cmd.output().expect("spawn command"); + assert!( + out.status.success(), + "{what} failed: {}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8(out.stdout).expect("utf8 stdout") +} + +fn run_ruchy(sub: &str, src: &str) -> String { + let dir = tempfile::tempdir().expect("tempdir"); + let file = write_source(dir.path(), src); + let mut cmd = ruchy(); + cmd.arg(sub).arg(file); + stdout_of(cmd, &format!("ruchy {sub} of:\n{src}\n")) +} + +/// `ruchy compile` (rustc) the source, then run the binary. +fn compile_and_run(src: &str) -> String { + let dir = tempfile::tempdir().expect("tempdir"); + let file = write_source(dir.path(), src); + let bin = dir.path().join("prog_bin"); + let mut cmd = ruchy(); + cmd.arg("compile") + .arg(&file) + .arg("-o") + .arg(&bin) + .timeout(Duration::from_secs(60)); + stdout_of(cmd, &format!("ruchy compile of:\n{src}\n")); + let mut run = Command::new(&bin); + run.timeout(Duration::from_secs(30)); + stdout_of(run, "compiled binary") +} + +fn squash(s: &str) -> String { + s.chars().filter(|c| !c.is_whitespace()).collect() +} + +/// Keywords that are Rust reserved words: transpiled as `r#kw`. +const RUST_RESERVED: &[&str] = &[ + "match", "type", "loop", "impl", "mod", "use", "as", "in", "where", "static", "const", "trait", + "struct", "enum", "try", "yield", "final", "abstract", "override", "async", "return", "break", + "continue", "if", "else", "for", "while", "let", "fn", "pub", "mut", "unsafe", +]; + +/// Ruchy-only keywords: plain Rust identifiers. +const RUCHY_ONLY: &[&str] = &[ + "extend", + "from", + "default", + "module", + "var", + "with", + "import", + "export", + "class", + "handle", + "handler", + "effect", + "property", + "private", + "protected", + "sealed", + "mixin", + "operator", + "interface", + "implements", + "receive", + "spawn", + "actor", + "lazy", + "catch", + "finally", + "throw", + "signal", + "infra", + "requires", + "ensures", + "invariant", + "decreases", + "fun", + "send", + "ask", +]; + +fn all_keywords() -> impl Iterator { + RUST_RESERVED.iter().chain(RUCHY_ONLY.iter()).copied() +} + +fn single_field_enum(kw: &str) -> String { + format!("enum Test {{\n Variant {{ {kw}: i32 }}\n}}\n") +} + +#[test] +fn test_enumfieldkw_1_every_keyword_parses_as_variant_field() { + for kw in all_keywords() { + run_ruchy("check", &single_field_enum(kw)); + } +} + +#[test] +fn test_enumfieldkw_1_reserved_keyword_field_is_raw_ident() { + for kw in RUST_RESERVED { + let rust = squash(&run_ruchy("transpile", &single_field_enum(kw))); + assert!( + rust.contains(&format!("Variant{{r#{kw}:i32}}")), + "`{kw}` must be emitted as r#{kw}: {rust}" + ); + } +} + +#[test] +fn test_enumfieldkw_1_ruchy_only_keyword_field_is_plain_ident() { + for kw in RUCHY_ONLY { + let rust = squash(&run_ruchy("transpile", &single_field_enum(kw))); + assert!( + rust.contains(&format!("Variant{{{kw}:i32}}")), + "`{kw}` must be emitted as a plain identifier: {rust}" + ); + } +} + +#[test] +fn test_enumfieldkw_1_proptest_shape_with_literal() { + let src = "fn main() {\n enum Test {\n Variant { enum: int }\n }\n \ + let x = Test::Variant { enum: 5 }\n println(x)\n}\n"; + run_ruchy("check", src); +} + +#[test] +fn test_enumfieldkw_1_all_keyword_fields_compile() { + let fields: Vec = all_keywords().map(|kw| format!("{kw}: i32")).collect(); + let values: Vec = all_keywords().map(|kw| format!("{kw}: 1")).collect(); + let src = format!( + "enum Test {{\n Variant {{ {} }},\n Empty\n}}\n\ + fun main() {{\n let _t = Test::Variant {{ {} }}\n println!(\"ok\")\n}}\n", + fields.join(", "), + values.join(", ") + ); + assert_eq!(compile_and_run(&src).trim(), "ok"); +} diff --git a/tests/eval_builtin_comprehensive.rs b/tests/eval_builtin_comprehensive.rs index a8bfb126c..0f4eeb583 100644 --- a/tests/eval_builtin_comprehensive.rs +++ b/tests/eval_builtin_comprehensive.rs @@ -345,13 +345,13 @@ fn test_println_multiple_args() { #[test] fn test_print_no_newline() { - // print() quotes strings, so output is "A""B" not AB + // print() writes a string's text, as the compiled print! does ruchy_cmd() .arg("-e") .arg("print(\"A\"); print(\"B\")") .assert() .success() - .stdout(predicate::str::contains("\"A\"\"B\"")); + .stdout(predicate::str::contains("AB")); } #[test] diff --git a/tests/extendkw_1_keyword_methods.rs b/tests/extendkw_1_keyword_methods.rs new file mode 100644 index 000000000..fc5dc75fc --- /dev/null +++ b/tests/extendkw_1_keyword_methods.rs @@ -0,0 +1,251 @@ +//! EXTENDKW-1: a keyword token after `.` is a method or field name. +//! +//! `v.extend([3])` failed with "Expected method name, tuple index, or 'await' +//! after '.'": `extend` lexes as `Token::Extend` (Ruchy's extension-block +//! keyword), and the postfix `.` parser only accepted `Token::Identifier` +//! (plus `send`/`ask`). The same held for `o.type`, `o.match(x)`, +//! `o.default()`, `o.from(x)`, and every other keyword. +//! +//! After `.` (and `?.`) a keyword is now the member name spelled as its +//! source text. `x.await` stays an await expression. Transpiled Rust escapes a +//! Rust reserved word as a raw identifier (`o.r#match(1)`). + +use assert_cmd::Command; +use std::path::Path; +use std::time::Duration; + +fn ruchy() -> Command { + let mut cmd = Command::cargo_bin("ruchy").expect("ruchy binary is built for tests"); + cmd.timeout(Duration::from_secs(30)); + cmd +} + +fn write_source(dir: &Path, src: &str) -> std::path::PathBuf { + let file = dir.join("prog.ruchy"); + std::fs::write(&file, src).expect("write ruchy source"); + file +} + +fn stdout_of(mut cmd: Command, what: &str) -> String { + let out = cmd.output().expect("spawn command"); + assert!( + out.status.success(), + "{what} failed:\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8(out.stdout).expect("utf8 stdout") +} + +fn check(src: &str) { + let dir = tempfile::tempdir().expect("tempdir"); + let file = write_source(dir.path(), src); + let mut cmd = ruchy(); + cmd.arg("check").arg(file); + stdout_of(cmd, &format!("ruchy check on:\n{src}\n")); +} + +fn transpile(dir: &Path, src: &str) -> String { + let file = write_source(dir, src); + let mut cmd = ruchy(); + cmd.arg("transpile").arg(file); + stdout_of(cmd, &format!("ruchy transpile on:\n{src}\n")) +} + +fn transpile_str(src: &str) -> String { + let dir = tempfile::tempdir().expect("tempdir"); + transpile(dir.path(), src) +} + +fn rustc_run(dir: &Path, rust: &str) -> String { + let main_rs = dir.join("main.rs"); + let bin = dir.join("prog_bin"); + std::fs::write(&main_rs, rust).expect("write main.rs"); + let mut rustc = Command::new("rustc"); + rustc + .args(["--edition", "2021", "-A", "warnings", "-o"]) + .arg(&bin) + .arg(&main_rs) + .timeout(Duration::from_secs(120)); + stdout_of(rustc, &format!("rustc on transpiled Rust:\n{rust}\n")); + let mut run = Command::new(&bin); + run.timeout(Duration::from_secs(30)); + stdout_of(run, "transpiled binary") +} + +/// Keywords that are Rust reserved words: transpiled as `r#kw`. +const RUST_RESERVED: &[&str] = &[ + "match", "type", "loop", "impl", "mod", "use", "as", "in", "where", "static", "const", "trait", + "struct", "enum", "try", "yield", "final", "abstract", "override", "async", "return", "break", + "continue", "if", "else", "for", "while", "let", "fn", "pub", "mut", "unsafe", +]; + +/// Ruchy-only keywords: plain Rust identifiers. +const RUCHY_ONLY: &[&str] = &[ + "extend", + "from", + "default", + "module", + "var", + "with", + "import", + "export", + "class", + "handle", + "handler", + "effect", + "property", + "private", + "protected", + "sealed", + "mixin", + "operator", + "interface", + "implements", + "receive", + "spawn", + "actor", + "lazy", + "catch", + "finally", + "throw", + "signal", + "infra", + "requires", + "ensures", + "invariant", + "decreases", + "fun", + "send", + "ask", +]; + +fn rust_member(kw: &str) -> String { + if RUST_RESERVED.contains(&kw) { + format!("r#{kw}") + } else { + kw.to_string() + } +} + +fn all_keywords() -> impl Iterator { + RUST_RESERVED.iter().chain(RUCHY_ONLY.iter()).copied() +} + +/// Strip whitespace so `o . r#match (1)` and `o.r#match(1)` compare equal. +fn squash(s: &str) -> String { + s.chars().filter(|c| !c.is_whitespace()).collect() +} + +fn method_src(kw: &str) -> String { + format!("fun probe(o: Widget) -> i32 {{\n o.{kw}(1)\n}}\n") +} + +fn field_src(kw: &str) -> String { + format!("fun probe(o: Widget) -> i32 {{\n o.{kw}\n}}\n") +} + +#[test] +fn test_extendkw_1_01_vec_extend_checks_clean() { + check("let mut v = [1, 2]\nv.extend([3])\nprintln(v.len())\n"); +} + +#[test] +fn test_extendkw_1_02_vec_extend_transpiles_to_method_call() { + let rust = transpile_str( + "fun main() {\n let mut v = [1, 2]\n v.extend([3])\n println(v.len())\n}\n", + ); + assert!( + squash(&rust).contains("v.extend("), + "expected a v.extend(..) call in:\n{rust}" + ); +} + +#[test] +fn test_extendkw_1_03_vec_extend_compiles_with_rustc() { + // A struct field typed Vec transpiles its literal to vec![..] (VECLIT-1); + // a bare `let mut v = [1, 2]` still transpiles to a fixed-size Rust array. + let src = "struct Bag {\n items: Vec,\n}\n\nfun main() {\n let mut b = Bag { items: [1, 2] }\n b.items.extend([3])\n println(b.items.len())\n}\n"; + let dir = tempfile::tempdir().expect("tempdir"); + let rust = transpile(dir.path(), src); + assert!( + squash(&rust).contains("b.items.extend([3])"), + "expected b.items.extend([3]) in:\n{rust}" + ); + let out = rustc_run(dir.path(), &rust); + assert_eq!(out.trim(), "3", "rust:\n{rust}"); +} + +#[test] +fn test_extendkw_1_04_every_keyword_is_a_method_name() { + for kw in all_keywords() { + let src = method_src(kw); + check(&src); + let rust = squash(&transpile_str(&src)); + let want = format!("o.{}(1", rust_member(kw)); + assert!( + rust.contains(&want), + "`o.{kw}(1)`: expected `{want}` in:\n{rust}" + ); + } +} + +#[test] +fn test_extendkw_1_05_every_keyword_is_a_field_name() { + // RESFIELD-1: a field named by a Rust reserved word transpiles to `r#kw`. + for kw in all_keywords() { + let src = field_src(kw); + check(&src); + let rust = squash(&transpile_str(&src)); + let want = format!("o.{}", rust_member(kw)); + assert!( + rust.contains(&want), + "`o.{kw}`: expected `{want}` in:\n{rust}" + ); + } +} + +#[test] +fn test_extendkw_1_05b_reserved_field_access_compiles_with_rustc() { + // RESFIELD-1: keyword field names in a declaration, a literal and an access. + let src = "struct Widget {\n type: i32,\n match: i32,\n}\n\nfun probe(o: Widget) -> i32 {\n o.type + o.match\n}\n\nfun main() {\n println(probe(Widget { type: 5, match: 2 }))\n}\n"; + check(src); + let dir = tempfile::tempdir().expect("tempdir"); + let rust = transpile(dir.path(), src); + assert_eq!(rustc_run(dir.path(), &rust).trim(), "7", "rust:\n{rust}"); +} + +#[test] +fn test_extendkw_1_06_keyword_method_chains() { + let src = "fun probe(o: Widget) -> i32 {\n o.extend(1).match(2).default().type(3)\n}\n"; + check(src); + let rust = squash(&transpile_str(src)); + assert!( + rust.contains("o.extend(1).r#match(2).default().r#type(3)"), + "chain lost a member:\n{rust}" + ); +} + +#[test] +fn test_extendkw_1_07_await_stays_await() { + let src = "async fun probe(x: i32) -> i32 {\n x.await\n}\n"; + check(src); + let rust = squash(&transpile_str(src)); + assert!(rust.contains("x.await"), "expected `x.await` in:\n{rust}"); + assert!(!rust.contains("r#await"), "await became a member:\n{rust}"); +} + +#[test] +fn test_extendkw_1_08_keyword_after_optional_chain() { + check("let o = {\"extend\": 1}\nlet a = o?.extend\nlet b = o?.type\nprintln(a)\n"); +} + +#[test] +fn test_extendkw_1_09_tuple_index_and_identifier_members_unchanged() { + let src = "fun probe(t: (i32, i32), o: Widget) -> i32 {\n t.0 + o.len() + o.size\n}\n"; + check(src); + let rust = squash(&transpile_str(src)); + for want in ["t.0", "o.len()", "o.size"] { + assert!(rust.contains(want), "expected `{want}` in:\n{rust}"); + } +} diff --git a/tests/fieldpop_1_field_methods.rs b/tests/fieldpop_1_field_methods.rs new file mode 100644 index 000000000..26a34b952 --- /dev/null +++ b/tests/fieldpop_1_field_methods.rs @@ -0,0 +1,345 @@ +//! FIELDPOP-1: a mutating array method called on a field (`self.items.pop()`, +//! `s.items.push(x)`, `o.items.pop()`) returns the method's own result and +//! writes the changed array back to the field. It used to evaluate the field +//! to a copy, run the array method on the copy, and return the updated copy: +//! `self.items.pop()` yielded the remaining array (so `.unwrap()` failed with +//! "Unknown array method: unwrap") and the field kept all its items. +//! +//! The field form must behave exactly like the same call on a local array +//! (`let mut a = [1, 2, 3]; a.pop()` returns `3`, leaves `a == [1, 2]`), so +//! most tests compare the two forms instead of fixing one semantics here. + +use assert_cmd::Command; +use std::path::Path; +use std::time::Duration; + +fn ruchy() -> Command { + let mut cmd = Command::cargo_bin("ruchy").expect("ruchy binary is built for tests"); + cmd.timeout(Duration::from_secs(30)); + cmd +} + +fn write_source(dir: &Path, src: &str) -> std::path::PathBuf { + let file = dir.join("prog.ruchy"); + std::fs::write(&file, src).expect("write ruchy source"); + file +} + +fn stdout_of(mut cmd: Command, what: &str) -> String { + let out = cmd.output().expect("spawn command"); + assert!( + out.status.success(), + "{what} failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8(out.stdout).expect("utf8 stdout") +} + +fn interpret(dir: &Path, src: &str) -> String { + let file = write_source(dir, src); + let mut cmd = ruchy(); + cmd.arg("run").arg(file); + stdout_of(cmd, "ruchy run") +} + +fn run_file(src: &str) -> String { + let dir = tempfile::tempdir().expect("tempdir"); + interpret(dir.path(), src) +} + +fn transpile(dir: &Path, src: &str) -> String { + let file = write_source(dir, src); + let mut cmd = ruchy(); + cmd.arg("transpile").arg(file); + stdout_of(cmd, "ruchy transpile") +} + +fn rustc_run(dir: &Path, rust: &str) -> String { + let main_rs = dir.join("main.rs"); + let bin = dir.join("prog_bin"); + std::fs::write(&main_rs, rust).expect("write main.rs"); + let mut rustc = Command::new("rustc"); + rustc + .args(["--edition", "2021", "-A", "warnings", "-o"]) + .arg(&bin) + .arg(&main_rs) + .timeout(Duration::from_secs(120)); + stdout_of(rustc, &format!("rustc on transpiled Rust:\n{rust}\n")); + let mut run = Command::new(&bin); + run.timeout(Duration::from_secs(30)); + stdout_of(run, "transpiled binary") +} + +/// Transpile, compile, run; assert interpreter == rustc output. Returns the Rust text. +fn differential(src: &str) -> String { + let dir = tempfile::tempdir().expect("tempdir"); + let rust = transpile(dir.path(), src); + let expected = interpret(dir.path(), src); + let actual = rustc_run(dir.path(), &rust); + assert_eq!( + actual, expected, + "transpiled Rust disagrees with the interpreter\nsource:\n{src}\nrust:\n{rust}" + ); + rust +} + +const STACK: &str = "struct Stack { items: Vec }\n"; + +/// `op` applied to a local array and to a struct field must print the same +/// result and leave the same array behind. +fn assert_field_matches_local(op: &str) { + let local = format!( + "fun main() {{\n let mut a = [3, 1, 2]\n println(a.{op})\n println(a)\n}}\n" + ); + let field = format!( + "{STACK}fun main() {{\n let mut s = Stack {{ items: vec![3, 1, 2] }}\n println(s.items.{op})\n println(s.items)\n}}\n" + ); + assert_eq!( + run_file(&field), + run_file(&local), + "field vs local for `{op}`" + ); +} + +#[test] +fn test_fieldpop_1_01_self_field_pop_returns_element_and_writes_back() { + let src = format!( + "{STACK}impl Stack {{ + fun pop_one(&mut self) -> i32 {{ + self.items.pop() + }} +}} +fun main() {{ + let mut s = Stack {{ items: vec![1, 2, 3] }} + println(s.pop_one()) + println(s.items.len()) + println(s.items) +}} +" + ); + assert_eq!(run_file(&src), "3\n2\n[1, 2]\n"); +} + +#[test] +fn test_fieldpop_1_02_self_field_push_writes_back() { + let src = format!( + "{STACK}impl Stack {{ + fun push_two(&mut self, x: i32) {{ + self.items.push(x) + self.items.push(x + 1) + }} +}} +fun main() {{ + let mut s = Stack {{ items: vec![1] }} + s.push_two(10) + println(s.items) +}} +" + ); + assert_eq!(run_file(&src), "[1, 10, 11]\n"); +} + +#[test] +fn test_fieldpop_1_03_struct_value_field_pop_and_push_in_main() { + let src = format!( + "{STACK}fun main() {{ + let mut s = Stack {{ items: vec![1, 2, 3] }} + let top = s.items.pop() + println(top) + println(s.items) + s.items.push(9) + println(s.items) +}} +" + ); + assert_eq!(run_file(&src), "3\n[1, 2]\n[1, 2, 9]\n"); +} + +#[test] +fn test_fieldpop_1_04_field_push_inside_while_loop_persists() { + let src = format!( + "{STACK}fun main() {{ + let mut s = Stack {{ items: vec![] }} + let mut i = 0 + while i < 3 {{ + s.items.push(i) + i = i + 1 + }} + println(s.items) +}} +" + ); + assert_eq!(run_file(&src), "[0, 1, 2]\n"); +} + +#[test] +fn test_fieldpop_1_05_object_literal_field_pop_and_push() { + let src = "fun main() { + let mut o = {items: [1, 2, 3]} + println(o.items.pop()) + o.items.push(7) + println(o.items) +} +"; + assert_eq!(run_file(src), "3\n[1, 2, 7]\n"); +} + +#[test] +fn test_fieldpop_1_06_class_field_push_pop_reference_semantics() { + let src = "class Bag { + items: Vec + new() { + self.items = [] + } + fun add(&mut self, x: i32) { self.items.push(x) } + fun take(&mut self) -> i32 { self.items.pop() } +} +let mut b = Bag::new() +let alias = b +b.add(4) +b.add(5) +println(b.take()) +println(b.items) +println(alias.items) +"; + assert_eq!(run_file(src), "5\n[4]\n[4]\n"); +} + +#[test] +fn test_fieldpop_1_07_empty_field_pop_matches_local() { + let local = + run_file("fun main() {\n let mut a = []\n println(a.pop())\n println(a)\n}\n"); + let field = run_file(&format!( + "{STACK}fun main() {{\n let mut s = Stack {{ items: vec![] }}\n println(s.items.pop())\n println(s.items)\n}}\n" + )); + assert_eq!(field, local); +} + +#[test] +fn test_fieldpop_1_08_mutating_forms_match_local() { + for op in ["pop()", "push(4)"] { + assert_field_matches_local(op); + } +} + +#[test] +fn test_fieldpop_1_09_methods_1_and_copying_forms_match_local() { + for op in [ + "sorted()", + "reversed()", + "drop(1)", + "append([4])", + "sort()", + "reverse()", + "len()", + ] { + assert_field_matches_local(op); + } +} + +#[test] +fn test_fieldpop_1_10_differential_push_pop_through_rustc() { + let src = format!( + "{STACK}impl Stack {{ + fun push_two(&mut self, x: i32) {{ + self.items.push(x) + self.items.push(x + 1) + }} + fun drop_top(&mut self) -> usize {{ + self.items.pop(); + self.items.len() + }} +}} +fun main() {{ + let mut s = Stack {{ items: vec![1, 2, 3] }} + println(s.drop_top()) + s.push_two(10) + println(s.items.len()) + println(s.items[s.items.len() - 1]) +}} +" + ); + differential(&src); +} + +// ---------- VECLIT-1: an array literal for a `Vec<…>` struct field ---------- + +#[test] +fn test_veclit_1_01_array_literal_for_vec_field_compiles_as_vec() { + let src = format!( + "{STACK}fun main() {{ + let mut s = Stack {{ items: [1, 2, 3] }} + s.items.push(4) + println(s.items.len()) +}} +" + ); + let rust = differential(&src); + let squashed: String = rust.chars().filter(|c| !c.is_whitespace()).collect(); + assert!(squashed.contains("items:vec![1,2,3]"), "rust:\n{rust}"); +} + +#[test] +fn test_veclit_1_02_nested_vec_field_and_fixed_array_field_untouched() { + let src = "struct Grid { rows: Vec>, fixed: [i32; 2] } +fun main() { + let g = Grid { rows: [vec![1], vec![2, 3]], fixed: [7, 8] } + println(g.rows.len()) + println(g.fixed[1]) +} +"; + let rust = differential(src); + let squashed: String = rust.chars().filter(|c| !c.is_whitespace()).collect(); + assert!(squashed.contains("fixed:[7,8]"), "rust:\n{rust}"); +} + +// ---------- RHLGA-1 review F6: in-place methods on nested fields ---------- + +const NESTED: &str = "struct Inner { items: Vec } +struct Outer { inner: Inner } +"; + +#[test] +fn test_fieldpop_1_11_nested_self_field_push_persists() { + let src = format!( + "{NESTED}impl Outer {{ + fun add(&mut self, x: i32) {{ + self.inner.items.push(x) + }} +}} +fun main() {{ + let mut o = Outer {{ inner: Inner {{ items: vec![1] }} }} + o.add(5) + o.add(3) + println(o.inner.items) +}} +" + ); + assert_eq!(run_file(&src), "[1, 5, 3]\n"); +} + +#[test] +fn test_fieldpop_1_12_nested_local_field_sort_and_pop_persist() { + let src = format!( + "{NESTED}fun main() {{ + let mut o = Outer {{ inner: Inner {{ items: vec![3, 1, 2] }} }} + o.inner.items.sort() + println(o.inner.items) + println(o.inner.items.pop()) + println(o.inner.items) +}} +" + ); + assert_eq!(run_file(&src), "[1, 2, 3]\n3\n[1, 2]\n"); +} + +#[test] +fn test_fieldpop_1_13_object_literal_nested_field_push_persists() { + let src = "fun main() { + let mut o = { a: { b: [1] } } + o.a.b.push(2) + println(o.a.b) +} +"; + assert_eq!(run_file(src), "[1, 2]\n"); +} diff --git a/tests/g2b_fix_a.rs b/tests/g2b_fix_a.rs new file mode 100644 index 000000000..1b3841aaa --- /dev/null +++ b/tests/g2b_fix_a.rs @@ -0,0 +1,379 @@ +//! G2B-FIX-A (RHLGA-1): review findings on the G2 part 2 branch. +//! +//! 1. A trait default method with a by-value `self` needs `where Self: Sized`. +//! 2. `&mut self` inference sees nested/indexed field writes, field `++`/`--`, +//! mutating std methods on a field and calls to mutating sibling methods. +//! 3. The `::` path marker survives `ruchy fmt` and non-callee transpilation. +//! 4. A user-defined `range` function is not the builtin range. +//! 5. An awaiting `main` keeps a declared non-unit return type. +//! +//! Every program is transpiled, compiled with rustc and run. + +#![allow(clippy::expect_used)] +#![allow(clippy::unwrap_used)] + +use ruchy::quality::formatter::Formatter; +use ruchy::{Parser, Transpiler}; +use std::process::Command; + +fn scratch_dir(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("g2b_fix_a_{}_{name}", std::process::id())); + std::fs::create_dir_all(&dir).expect("scratch dir"); + dir +} + +fn transpile(code: &str) -> String { + let ast = Parser::new(code).parse().expect("parses"); + Transpiler::new() + .transpile_to_program(&ast) + .expect("transpiles") + .to_string() +} + +fn compiled_output(code: &str, name: &str) -> String { + let rust = transpile(code); + let dir = scratch_dir(name); + let src = dir.join("main.rs"); + let bin = dir.join("main"); + std::fs::write(&src, &rust).expect("write rust"); + let out = Command::new("rustc") + .args(["--edition", "2021", "-A", "warnings", "-o"]) + .arg(&bin) + .arg(&src) + .output() + .expect("rustc runs"); + assert!( + out.status.success(), + "rustc rejected the transpiled program:\n{}\n--- rust ---\n{rust}", + String::from_utf8_lossy(&out.stderr) + ); + let run = Command::new(&bin).output().expect("binary runs"); + assert!(run.status.success(), "binary failed: {run:?}"); + String::from_utf8_lossy(&run.stdout).into_owned() +} + +/// Whitespace-free token text, so `where Self : Sized` matches `whereSelf:Sized`. +fn squash(rust: &str) -> String { + rust.chars().filter(|c| !c.is_whitespace()).collect() +} + +// ------------------------------------------- 1. trait default by-value self + +const TRAIT_DEFAULT_OWNED: &str = r" +trait Named { + fun id(self) -> i32 + fun consume(self) -> i32 { + self.id() + 1 + } +} +struct Token { + v: i32 +} +impl Named for Token { + fun id(self) -> i32 { + self.v + } +} +fun main() { + let t = Token { v: 41 } + println(t.consume()) +} +"; + +#[test] +fn test_g2bfa1_trait_default_owned_self_compiles() { + assert_eq!(compiled_output(TRAIT_DEFAULT_OWNED, "fa1_default"), "42\n"); +} + +#[test] +fn test_g2bfa1_trait_default_owned_self_gets_sized_bound() { + let rust = squash(&transpile(TRAIT_DEFAULT_OWNED)); + assert!( + rust.contains("fnconsume(self)->i32whereSelf:Sized{"), + "default by-value method needs `where Self: Sized`: {rust}" + ); + assert!( + rust.contains("fnid(self)->i32;"), + "a bodiless by-value declaration needs no bound: {rust}" + ); +} + +#[test] +fn test_g2bfa1_spec_7_1_owned_self_default_compiles() { + let code = r#" +trait Actor { + fun launch(self) -> i32 { + self.id() * 2 + } + fun id(&self) -> i32 +} +struct Worker { + n: i32 +} +impl Actor for Worker { + fun id(&self) -> i32 { + self.n + } +} +fun main() { + let w = Worker { n: 21 } + println(w.launch()) +} +"#; + assert_eq!(compiled_output(code, "fa1_spawn"), "42\n"); +} + +#[test] +fn test_g2bfa1_trait_default_borrowed_self_has_no_bound() { + let code = r" +trait Shown { + fun show(&self) -> i32 { + 7 + } +} +fun main() { +} +"; + let rust = squash(&transpile(code)); + assert!(rust.contains("fnshow(&self)->i32{"), "{rust}"); + assert!(!rust.contains("Sized"), "{rust}"); +} + +// ---------------------------------------------- 2. &mut self inference + +fn assert_mut_self(code: &str, method: &str) { + let rust = squash(&transpile(code)); + assert!( + rust.contains(&format!("fn{method}(&mutself")), + "`{method}` mutates self and must take &mut self: {rust}" + ); +} + +#[test] +fn test_g2bfa2_nested_field_assignment_is_mut_self() { + let code = r" +struct Inner { + n: i32 +} +struct Outer { + inner: Inner +} +impl Outer { + fun bump(self) { + self.inner.n = self.inner.n + 1 + } +} +fun main() { + let mut o = Outer { inner: Inner { n: 1 } } + o.bump() + println(o.inner.n) +} +"; + assert_mut_self(code, "bump"); + assert_eq!(compiled_output(code, "fa2_nested"), "2\n"); +} + +#[test] +fn test_g2bfa2_indexed_field_assignment_is_mut_self() { + let code = r" +struct Grid { + cells: Vec +} +impl Grid { + fun set(self, i: usize, v: i32) { + self.cells[i] = v + } +} +fun main() { + let mut g = Grid { cells: vec![0, 0, 0] } + g.set(1, 5) + println(g.cells[1]) +} +"; + assert_mut_self(code, "set"); + assert_eq!(compiled_output(code, "fa2_index"), "5\n"); +} + +#[test] +fn test_g2bfa2_field_increment_is_mut_self() { + let code = r" +struct Counter { + count: i32 +} +impl Counter { + fun tick(self) -> i32 { + self.count++ + } + fun untick(self) -> i32 { + --self.count + } +} +fun main() { + let mut c = Counter { count: 0 } + c.tick() + c.tick() + c.untick() + println(c.count) +} +"; + assert_mut_self(code, "tick"); + assert_mut_self(code, "untick"); + assert_eq!(compiled_output(code, "fa2_incr"), "1\n"); +} + +#[test] +fn test_g2bfa2_mutating_std_method_on_field_is_mut_self() { + let code = r" +struct Bag { + items: Vec +} +impl Bag { + fun add(self, x: i32) { + self.items.push(x) + } + fun size(self) -> usize { + self.items.len() + } +} +fun main() { + let mut b = Bag { items: vec![] } + b.add(3) + b.add(4) + println(b.items.len()) +} +"; + assert_mut_self(code, "add"); + let rust = squash(&transpile(code)); + assert!( + rust.contains("fnsize(self)->usize"), + "a read-only method stays an owned receiver: {rust}" + ); + assert_eq!(compiled_output(code, "fa2_push"), "2\n"); +} + +#[test] +fn test_g2bfa2_calling_mutating_sibling_is_mut_self() { + // examples/self_hosting/lexer.ruchy: skip_whitespace(self) calls self.advance() + let code = r" +struct Lexer { + pos: i32 +} +impl Lexer { + fun skip_whitespace(self) { + while self.pos < 3 { + self.advance() + } + } + fun skip_twice(self) { + self.skip_whitespace() + } + fun advance(self) { + self.pos = self.pos + 1 + } +} +fun main() { + let mut l = Lexer { pos: 0 } + l.skip_twice() + println(l.pos) +} +"; + assert_mut_self(code, "advance"); + assert_mut_self(code, "skip_whitespace"); + assert_mut_self(code, "skip_twice"); + assert_eq!(compiled_output(code, "fa2_sibling"), "3\n"); +} + +// ------------------------------------------------- 3. `::` path marker + +fn fmt(code: &str) -> String { + let ast = Parser::new(code).parse().expect("parses"); + let mut formatter = Formatter::new(); + formatter.set_source(code); + formatter.format(&ast).expect("formats") +} + +#[test] +fn test_g2bfa3_fmt_keeps_path_separator() { + let out = fmt("helpers::double(4)\n"); + assert!(out.contains("helpers::double(4)"), "fmt output: {out}"); +} + +#[test] +fn test_g2bfa3_fmt_keeps_non_callee_path_and_dot_field() { + let out = fmt("let m = i32::MAX\nlet f = p.x\n"); + assert!(out.contains("i32::MAX"), "fmt output: {out}"); + assert!(out.contains("p.x"), "fmt output: {out}"); + assert!(!out.contains("p::x"), "fmt output: {out}"); +} + +#[test] +fn test_g2bfa3_fmt_round_trip_is_stable() { + let once = fmt("fun main() {\n let v = helpers::double(4)\n}\n"); + let twice = fmt(&once); + assert_eq!(once, twice); +} + +#[test] +fn test_g2bfa3_lowercase_root_path_constant_transpiles_with_colons() { + let code = "fun main() {\n let m = i32::MAX\n println(m)\n}\n"; + let rust = squash(&transpile(code)); + assert!(rust.contains("i32::MAX"), "{rust}"); + assert_eq!(compiled_output(code, "fa3_max"), "2147483647\n"); +} + +// ---------------------------------------------- 4. user-defined `range` + +const USER_RANGE: &str = r" +fun range(a: i32, b: i32) -> Vec { + vec![a, b] +} +fun main() { + println(range(0, 5).count()) + println(range(0, 5).sum()) +} +"; + +#[test] +fn test_g2bfa4_user_defined_range_is_called_and_iterated() { + let rust = squash(&transpile(USER_RANGE)); + assert!(rust.contains("range(0,5).iter().count()"), "{rust}"); + assert_eq!(compiled_output(USER_RANGE, "fa4_user_range"), "2\n5\n"); +} + +#[test] +fn test_g2bfa4_builtin_range_count_still_direct() { + let code = "fun main() {\n let n = range(0, 4).count()\n println(n)\n}\n"; + assert_eq!(compiled_output(code, "fa4_builtin_range"), "4\n"); +} + +// --------------------------------------- 5. awaiting main return type + +#[test] +fn test_g2bfa5_async_main_with_result_return_type_compiles() { + let code = r" +async fun value() -> i32 { + 5 +} +async fun main() -> Result<(), String> { + let v = value().await + println(v) + Ok(()) +} +"; + assert_eq!(compiled_output(code, "fa5_result_main"), "5\n"); +} + +#[test] +fn test_g2bfa5_async_main_propagates_error_exit() { + let code = r#" +async fun check(x: i32) -> Result { + if x > 0 { Ok(x) } else { Err(String::from("neg")) } +} +async fun main() -> Result<(), String> { + let v = check(3).await? + println(v) + Ok(()) +} +"#; + assert_eq!(compiled_output(code, "fa5_try_main"), "3\n"); +} diff --git a/tests/g2b_fix_b.rs b/tests/g2b_fix_b.rs new file mode 100644 index 000000000..d145e27b7 --- /dev/null +++ b/tests/g2b_fix_b.rs @@ -0,0 +1,81 @@ +//! G2BFB (RHLGA-1): review fixes on the G2 part 2 branch. +//! +//! Item 5: a nested module keeps the visibility the parser recorded +//! (`pub(crate)`, `pub(super)`), instead of widening every `pub(..)` to `pub`. +//! The program is transpiled, compiled with rustc and run. + +#![allow(clippy::expect_used)] +#![allow(clippy::unwrap_used)] + +use ruchy::{Parser, Transpiler}; +use std::process::Command; + +fn transpile(code: &str) -> String { + let ast = Parser::new(code).parse().expect("parses"); + Transpiler::new() + .transpile_to_program(&ast) + .expect("transpiles") + .to_string() +} + +fn compile_and_run(rust: &str) -> String { + let dir = tempfile::tempdir().expect("scratch dir"); + let src = dir.path().join("main.rs"); + let bin = dir.path().join("main"); + std::fs::write(&src, rust).expect("write rust"); + let out = Command::new("rustc") + .args(["--edition", "2021", "-A", "warnings", "-o"]) + .arg(&bin) + .arg(&src) + .output() + .expect("rustc runs"); + assert!( + out.status.success(), + "rustc rejected the transpiled program:\n{}\n--- rust ---\n{rust}", + String::from_utf8_lossy(&out.stderr) + ); + let run = Command::new(&bin).output().expect("binary runs"); + assert!(run.status.success(), "binary failed: {run:?}"); + String::from_utf8_lossy(&run.stdout).into_owned() +} + +const NESTED_VISIBILITY: &str = r#" +mod outer { + pub(crate) mod inner { + pub fun value() -> i32 { 7 } + } + pub(super) mod up { + pub fun value() -> i32 { 5 } + } + pub mod open { + pub fun value() -> i32 { 1 } + } +} +fun main() { + println!("{}", outer::inner::value() + outer::up::value() + outer::open::value()) +} +"#; + +#[test] +fn test_g2bfb5_01_nested_pub_crate_module_keeps_its_visibility() { + let rust = transpile(NESTED_VISIBILITY); + assert!(rust.contains("pub (crate) mod inner"), "{rust}"); +} + +#[test] +fn test_g2bfb5_02_nested_pub_super_module_keeps_its_visibility() { + let rust = transpile(NESTED_VISIBILITY); + assert!(rust.contains("pub (super) mod up"), "{rust}"); +} + +#[test] +fn test_g2bfb5_03_nested_plain_pub_module_stays_pub() { + let rust = transpile(NESTED_VISIBILITY); + assert!(rust.contains("pub mod open"), "{rust}"); +} + +#[test] +fn test_g2bfb5_04_nested_module_visibilities_compile_and_run() { + let rust = transpile(NESTED_VISIBILITY); + assert_eq!(compile_and_run(&rust).trim(), "13", "{rust}"); +} diff --git a/tests/g2b_runfix_1.rs b/tests/g2b_runfix_1.rs new file mode 100644 index 000000000..2a4a61467 --- /dev/null +++ b/tests/g2b_runfix_1.rs @@ -0,0 +1,291 @@ +//! RHLGA-1 G2b run fixes: three interpreter defects measured with `ruchy run`. +//! +//! - IDXCOMPOUND-1: compound assignment to an index target (`v[1] += 5`), a +//! field of an element (`o.items[0].z += 1`) and an element of a field +//! (`o.xs[1] -= 2`) failed with "Invalid compound assignment target". +//! - IDXPUSHWB-1: an in-place array method on a chain that contains an index +//! (`t.rows[0].vals.push(9)`, `m[0].push(x)`) changed a copy and was lost. +//! - COUNTITER-1 (interpreter half): `"abc".chars().count()` failed with +//! "Unknown array method: count". +//! +//! Each case states the stdout that Rust prints for the same program. A +//! hand-written Rust reference is compiled with rustc to prove that string, +//! and `ruchy run` must print it too. +//! +//! `ruchy compile` output is not compared here: at fefd3886 the transpiler +//! emits `v[1 as usize].clone() += 5` and `t.rows[0 as usize].clone().vals +//! .push(9)` (rustc E0067 / a lost write) and `.chars().iter().count()` +//! (E0599). Those are the transpiler halves, fixed in another worktree; every +//! program in this file uses a feature one of them breaks. + +use assert_cmd::Command; +use std::path::Path; +use std::time::Duration; + +fn stdout_of(mut cmd: Command, what: &str) -> String { + let out = cmd.output().expect("spawn command"); + assert!( + out.status.success(), + "{what} failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8(out.stdout).expect("utf8 stdout") +} + +fn ruchy_run(dir: &Path, src: &str) -> String { + let file = dir.join("prog.ruchy"); + std::fs::write(&file, src).expect("write ruchy source"); + let mut cmd = Command::cargo_bin("ruchy").expect("ruchy binary is built for tests"); + cmd.arg("run").arg(file).timeout(Duration::from_secs(10)); + stdout_of(cmd, "ruchy run") +} + +fn rustc_run(dir: &Path, rust: &str) -> String { + let main_rs = dir.join("reference.rs"); + let bin = dir.join("reference_bin"); + std::fs::write(&main_rs, rust).expect("write reference.rs"); + let mut rustc = Command::new("rustc"); + rustc + .args(["--edition", "2021", "-A", "warnings", "-o"]) + .arg(&bin) + .arg(&main_rs) + .timeout(Duration::from_secs(120)); + stdout_of(rustc, "rustc on the Rust reference"); + let mut run = Command::new(&bin); + run.timeout(Duration::from_secs(10)); + stdout_of(run, "Rust reference binary") +} + +/// Rust prints `expected` for `rust`, and `ruchy run` prints it for `ruchy`. +fn assert_matches_rust(ruchy: &str, rust: &str, expected: &str) { + let dir = tempfile::tempdir().expect("tempdir"); + assert_eq!(rustc_run(dir.path(), rust), expected, "Rust reference"); + assert_eq!(ruchy_run(dir.path(), ruchy), expected, "ruchy run"); +} + +// ---------------------------------------------------------------- IDXCOMPOUND-1 + +#[test] +fn test_idxcompound_1_local_array_all_operators() { + let ruchy = r#" +fun main() { + let mut v = [10, 20, 30, 40, 50] + v[0] += 5 + v[1] -= 3 + v[2] *= 2 + v[3] /= 3 + v[4] %= 7 + println("{} {} {} {} {}", v[0], v[1], v[2], v[3], v[4]) +} +"#; + let rust = r#" +fn main() { + let mut v = [10, 20, 30, 40, 50]; + v[0] += 5; + v[1] -= 3; + v[2] *= 2; + v[3] /= 3; + v[4] %= 7; + println!("{} {} {} {} {}", v[0], v[1], v[2], v[3], v[4]); +} +"#; + assert_matches_rust(ruchy, rust, "15 17 60 13 1\n"); +} + +#[test] +fn test_idxcompound_1_variable_index_in_loop() { + let ruchy = r#" +fun main() { + let mut v = [1, 1, 1, 1] + for i in 0..4 { + v[i] += i * 10 + } + println("{} {} {} {}", v[0], v[1], v[2], v[3]) +} +"#; + let rust = r#" +fn main() { + let mut v = [1, 1, 1, 1]; + for i in 0..4 { + v[i] += i * 10; + } + println!("{} {} {} {}", v[0], v[1], v[2], v[3]); +} +"#; + assert_matches_rust(ruchy, rust, "1 11 21 31\n"); +} + +#[test] +fn test_idxcompound_1_field_of_element_and_element_of_field() { + let ruchy = r#" +struct P { z: i32 } +struct O { items: Vec

, xs: Vec } +fun main() { + let mut o = O { items: vec![P { z: 1 }, P { z: 7 }], xs: vec![10, 20, 30] } + o.items[0].z += 1 + o.items[1].z *= 3 + o.xs[1] -= 2 + o.xs[2] /= 4 + println("{} {} {} {} {}", o.items[0].z, o.items[1].z, o.xs[0], o.xs[1], o.xs[2]) +} +"#; + let rust = r#" +struct P { z: i32 } +struct O { items: Vec

, xs: Vec } +fn main() { + let mut o = O { items: vec![P { z: 1 }, P { z: 7 }], xs: vec![10, 20, 30] }; + o.items[0].z += 1; + o.items[1].z *= 3; + o.xs[1] -= 2; + o.xs[2] /= 4; + println!("{} {} {} {} {}", o.items[0].z, o.items[1].z, o.xs[0], o.xs[1], o.xs[2]); +} +"#; + assert_matches_rust(ruchy, rust, "2 21 10 18 7\n"); +} + +#[test] +fn test_idxcompound_1_nested_index_and_index_of_nested_field() { + let ruchy = r#" +struct G { grid: Vec> } +fun main() { + let mut m = vec![vec![1, 2], vec![3, 4]] + m[1][0] += 100 + let mut g = G { grid: vec![vec![5, 6], vec![7, 8]] } + g.grid[0][1] -= 6 + println("{} {} {}", m[1][0], g.grid[0][1], g.grid[1][1]) +} +"#; + let rust = r#" +struct G { grid: Vec> } +fn main() { + let mut m = vec![vec![1, 2], vec![3, 4]]; + m[1][0] += 100; + let mut g = G { grid: vec![vec![5, 6], vec![7, 8]] }; + g.grid[0][1] -= 6; + println!("{} {} {}", m[1][0], g.grid[0][1], g.grid[1][1]); +} +"#; + assert_matches_rust(ruchy, rust, "103 0 8\n"); +} + +// ---------------------------------------------------------------- IDXPUSHWB-1 + +#[test] +fn test_idxpushwb_1_push_through_field_index_field_chain() { + let ruchy = r#" +struct R { vals: Vec } +struct T { rows: Vec } +fun main() { + let mut t = T { rows: vec![R { vals: vec![1] }] } + t.rows[0].vals.push(9) + println("{:?}", t.rows[0].vals) +} +"#; + let rust = r#" +struct R { vals: Vec } +struct T { rows: Vec } +fn main() { + let mut t = T { rows: vec![R { vals: vec![1] }] }; + t.rows[0].vals.push(9); + println!("{:?}", t.rows[0].vals); +} +"#; + assert_matches_rust(ruchy, rust, "[1, 9]\n"); +} + +#[test] +fn test_idxpushwb_1_methods_on_element_of_local_array() { + let ruchy = r#" +fun main() { + let mut m = vec![vec![3, 1, 2], vec![5]] + m[0].push(0) + m[0].sort() + let i = 1 + m[i].insert(0, 4) + m[i].pop() + println("{:?} {:?}", m[0], m[1]) +} +"#; + let rust = r#" +fn main() { + let mut m = vec![vec![3, 1, 2], vec![5]]; + m[0].push(0); + m[0].sort(); + let i = 1; + m[i].insert(0, 4); + m[i].pop(); + println!("{:?} {:?}", m[0], m[1]); +} +"#; + assert_matches_rust(ruchy, rust, "[0, 1, 2, 3] [4]\n"); +} + +#[test] +fn test_idxpushwb_1_sort_and_remove_through_variable_index() { + let ruchy = r#" +struct R { vals: Vec } +struct T { rows: Vec } +fun main() { + let mut t = T { rows: vec![R { vals: vec![1] }, R { vals: vec![9, 4, 6] }] } + let i = 1 + t.rows[i].vals.sort() + let r = t.rows[i].vals.remove(0) + println("{} {:?} {:?}", r, t.rows[i].vals, t.rows[0].vals) +} +"#; + let rust = r#" +struct R { vals: Vec } +struct T { rows: Vec } +fn main() { + let mut t = T { rows: vec![R { vals: vec![1] }, R { vals: vec![9, 4, 6] }] }; + let i = 1; + t.rows[i].vals.sort(); + let r = t.rows[i].vals.remove(0); + println!("{} {:?} {:?}", r, t.rows[i].vals, t.rows[0].vals); +} +"#; + assert_matches_rust(ruchy, rust, "4 [6, 9] [1]\n"); +} + +// ---------------------------------------------------------------- COUNTITER-1 + +#[test] +fn test_countiter_1_chars_count_matches_rust() { + let ruchy = r#" +fun main() { + let s = "abc" + let n = s.chars().count() + let u = "héllo".chars().count() + println("{} {} {}", n, u, "".chars().count()) +} +"#; + let rust = r#" +fn main() { + let s = "abc"; + let n = s.chars().count(); + let u = "héllo".chars().count(); + println!("{} {} {}", n, u, "".chars().count()); +} +"#; + assert_matches_rust(ruchy, rust, "3 5 0\n"); +} + +#[test] +fn test_countiter_1_iter_and_bytes_count_matches_rust() { + let ruchy = r#" +fun main() { + let v = vec![1, 2, 3, 4] + let e: Vec = vec![] + println("{} {} {}", v.iter().count(), e.iter().count(), "héllo".bytes().count()) +} +"#; + let rust = r#" +fn main() { + let v = vec![1, 2, 3, 4]; + let e: Vec = vec![]; + println!("{} {} {}", v.iter().count(), e.iter().count(), "héllo".bytes().count()); +} +"#; + assert_matches_rust(ruchy, rust, "4 0 6\n"); +} diff --git a/tests/g2b_runfix_2.rs b/tests/g2b_runfix_2.rs new file mode 100644 index 000000000..70e507235 --- /dev/null +++ b/tests/g2b_runfix_2.rs @@ -0,0 +1,380 @@ +//! RHLGA-1 G2b run fixes, part 2: four interpreter defects measured with +//! `ruchy run` at a5dc8398. +//! +//! - FMTSPEC-1: the format engine substituted only `{}`/`{:?}`; +//! `println("[{:>5}] [{:.2}] [{0}]", 7, 3.14159)` printed the spec text +//! and appended the values. Rust prints `[ 7] [3.14] [7]`. +//! - EPRINTLN-1: `eprintln`/`eprint` were "Undefined function". +//! - FORMATFN-1 (interpreter half): `format("{:?}", [1, 2])` failed with +//! "Unknown builtin __builtin_format__". +//! - DICTIDX-1 (interpreter half): `d["k"].push(2)` on an object failed with +//! "Array index must be an integer"; string keys now work at any link of an +//! index/field chain for read, assignment, compound assignment and in-place +//! methods. +//! +//! Each case states the output Rust prints for the same program. A +//! hand-written Rust reference is compiled with rustc to prove that string, +//! and `ruchy run` must print it too. `ruchy compile` parity is the +//! transpiler half, fixed in another worktree. + +use assert_cmd::Command; +use std::path::Path; +use std::time::Duration; + +struct Output { + stdout: String, + stderr: String, +} + +fn output_of(mut cmd: Command, what: &str) -> Output { + let out = cmd.output().expect("spawn command"); + assert!( + out.status.success(), + "{what} failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + Output { + stdout: String::from_utf8(out.stdout).expect("utf8 stdout"), + stderr: String::from_utf8(out.stderr).expect("utf8 stderr"), + } +} + +fn ruchy_cmd(dir: &Path, src: &str) -> Command { + let file = dir.join("prog.ruchy"); + std::fs::write(&file, src).expect("write ruchy source"); + let mut cmd = Command::cargo_bin("ruchy").expect("ruchy binary is built for tests"); + cmd.arg("run").arg(file).timeout(Duration::from_secs(10)); + cmd +} + +fn rustc_run(dir: &Path, rust: &str) -> Output { + let main_rs = dir.join("reference.rs"); + let bin = dir.join("reference_bin"); + std::fs::write(&main_rs, rust).expect("write reference.rs"); + let mut rustc = Command::new("rustc"); + rustc + .args(["--edition", "2021", "-A", "warnings", "-o"]) + .arg(&bin) + .arg(&main_rs) + .timeout(Duration::from_secs(120)); + output_of(rustc, "rustc on the Rust reference"); + let mut run = Command::new(&bin); + run.timeout(Duration::from_secs(10)); + output_of(run, "Rust reference binary") +} + +/// Rust prints `expected` for `rust`, and `ruchy run` prints it for `ruchy`. +fn assert_matches_rust(ruchy: &str, rust: &str, expected: &str) { + let dir = tempfile::tempdir().expect("tempdir"); + assert_eq!( + rustc_run(dir.path(), rust).stdout, + expected, + "Rust reference" + ); + let got = output_of(ruchy_cmd(dir.path(), ruchy), "ruchy run"); + assert_eq!(got.stdout, expected, "ruchy run"); +} + +// ---------------------------------------------------------------- FMTSPEC-1 + +#[test] +fn test_fmtspec_1_brief_case_width_precision_positional() { + assert_matches_rust( + r#" +fun main() { + println("[{:>5}] [{:.2}] [{0}]", 7, 3.14159) +} +"#, + r#" +fn main() { + println!("[{:>5}] [{:.2}] [{0}]", 7, 3.14159); +} +"#, + "[ 7] [3.14] [7]\n", + ); +} + +#[test] +fn test_fmtspec_1_inline_named_args_fill_align_sign_zero() { + assert_matches_rust( + r#" +fun main() { + let name = "ab" + let n = 42 + println("[{name:>6}] [{n:<4}] [{n:^6}] [{n:+}] [{n:05}] [{:+06}]", -7) +} +"#, + r#" +fn main() { + let name = "ab"; + let n = 42; + println!("[{name:>6}] [{n:<4}] [{n:^6}] [{n:+}] [{n:05}] [{:+06}]", -7); +} +"#, + "[ ab] [42 ] [ 42 ] [+42] [00042] [-00007]\n", + ); +} + +#[test] +fn test_fmtspec_1_explicit_named_args() { + assert_matches_rust( + r#" +fun main() { + println("{a}-{b}-{a}", a = 1, b = "z") +} +"#, + r#" +fn main() { + println!("{a}-{b}-{a}", a = 1, b = "z"); +} +"#, + "1-z-1\n", + ); +} + +#[test] +fn test_fmtspec_1_radix_and_alternate() { + assert_matches_rust( + r#" +fun main() { + println("{:x} {:X} {:b} {:o} {:#x} {:#b} {:#o} {:08b} {:#06x}", 255, 255, 5, 8, 255, 5, 8, 5, 255) +} +"#, + r#" +fn main() { + println!("{:x} {:X} {:b} {:o} {:#x} {:#b} {:#o} {:08b} {:#06x}", 255, 255, 5, 8, 255, 5, 8, 5, 255); +} +"#, + "ff FF 101 10 0xff 0b101 0o10 00000101 0x00ff\n", + ); +} + +#[test] +fn test_fmtspec_1_floats_exp_width_precision() { + assert_matches_rust( + r#" +fun main() { + println("{:e} [{:10.3}] [{:<10.1}] {:+.2} {:08.2} {:E}", 1234.5, 3.14159, 2.75, 1.5, -3.14159, 0.00012) +} +"#, + r#" +fn main() { + println!("{:e} [{:10.3}] [{:<10.1}] {:+.2} {:08.2} {:E}", 1234.5, 3.14159, 2.75, 1.5, -3.14159, 0.00012); +} +"#, + "1.2345e3 [ 3.142] [2.8 ] +1.50 -0003.14 1.2E-4\n", + ); +} + +#[test] +fn test_fmtspec_1_strings_fill_and_truncate() { + assert_matches_rust( + r#" +fun main() { + println("[{:>8}] [{:*^9}] [{:.3}] [{:-<6}] [{:>6.2}]", "hi", "mid", "abcdef", "x", "hello") +} +"#, + r#" +fn main() { + println!("[{:>8}] [{:*^9}] [{:.3}] [{:-<6}] [{:>6.2}]", "hi", "mid", "abcdef", "x", "hello"); +} +"#, + "[ hi] [***mid***] [abc] [x-----] [ he]\n", + ); +} + +#[test] +fn test_fmtspec_1_escapes_debug_and_pretty_debug() { + assert_matches_rust( + r#" +fun main() { + println("{{}} {{{}}} {:?} {:>5?}|", 1, "q", 3) + println("{:#?}", [1, 2]) +} +"#, + r#" +fn main() { + println!("{{}} {{{}}} {:?} {:>5?}|", 1, "q", 3); + println!("{:#?}", vec![1, 2]); +} +"#, + "{} {1} \"q\" 3|\n[\n 1,\n 2,\n]\n", + ); +} + +#[test] +fn test_fmtspec_1_format_macro_and_print_share_the_engine() { + assert_matches_rust( + r#" +fun main() { + let w = 3 + let s = format!("<{:>4}|{w:02}>", "a") + print("{:<3}|", 1) + println("{}", s) +} +"#, + r#" +fn main() { + let w = 3; + let s = format!("<{:>4}|{w:02}>", "a"); + print!("{:<3}|", 1); + println!("{}", s); +} +"#, + "1 |< a|03>\n", + ); +} + +#[test] +fn test_fmtspec_1_width_from_argument_is_an_error_not_wrong_output() { + let dir = tempfile::tempdir().expect("tempdir"); + let out = ruchy_cmd( + dir.path(), + "fun main() {\n println(\"[{:1$}]\", 7, 5)\n}\n", + ) + .output() + .expect("spawn ruchy"); + assert!(!out.status.success(), "must fail, got {:?}", out); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("1$"), "error must name the spec: {stderr}"); + assert!(!String::from_utf8_lossy(&out.stdout).contains('[')); +} + +// ---------------------------------------------------------------- EPRINTLN-1 + +#[test] +fn test_eprintln_1_eprintln_and_eprint_write_stderr() { + let ruchy = r#" +fun main() { + eprintln("x {}", 1) + eprint("y") + eprint("{:>3}\n", 7) + eprintln("{:?}", "q") + println("out") +} +"#; + let rust = r#" +fn main() { + eprintln!("x {}", 1); + eprint!("y"); + eprint!("{:>3}\n", 7); + eprintln!("{:?}", "q"); + println!("out"); +} +"#; + let dir = tempfile::tempdir().expect("tempdir"); + let reference = rustc_run(dir.path(), rust); + assert_eq!(reference.stdout, "out\n"); + assert_eq!(reference.stderr, "x 1\ny 7\n\"q\"\n"); + let got = output_of(ruchy_cmd(dir.path(), ruchy), "ruchy run"); + assert_eq!(got.stdout, reference.stdout, "ruchy stdout"); + assert_eq!(got.stderr, reference.stderr, "ruchy stderr"); +} + +// ---------------------------------------------------------------- FORMATFN-1 + +#[test] +fn test_formatfn_1_format_function_form_matches_macro() { + assert_matches_rust( + r#" +fun main() { + let s = format("{:?}", [1, 2]) + let t = format("{:>4}|{}", 7, "z") + println("{} {}", s, t) +} +"#, + r#" +fn main() { + let s = format!("{:?}", vec![1, 2]); + let t = format!("{:>4}|{}", 7, "z"); + println!("{} {}", s, t); +} +"#, + "[1, 2] 7|z\n", + ); +} + +// ---------------------------------------------------------------- DICTIDX-1 + +#[test] +fn test_dictidx_1_push_through_string_key() { + assert_matches_rust( + r#" +fun main() { + let mut d = {"k": [1]} + d["k"].push(2) + println("{:?}", d["k"]) +} +"#, + r#" +use std::collections::HashMap; +fn main() { + let mut d: HashMap<&str, Vec> = HashMap::new(); + d.insert("k", vec![1]); + d.get_mut("k").unwrap().push(2); + println!("{:?}", d["k"]); +} +"#, + "[1, 2]\n", + ); +} + +#[test] +fn test_dictidx_1_assign_and_compound_assign_string_key() { + assert_matches_rust( + r#" +fun main() { + let mut d = {"n": 1} + d["n"] = 5 + d["n"] += 1 + d["m"] = 10 + println("{} {}", d["n"], d["m"]) +} +"#, + r#" +use std::collections::HashMap; +fn main() { + let mut d: HashMap<&str, i64> = HashMap::new(); + d.insert("n", 1); + d.insert("n", 5); + *d.get_mut("n").unwrap() += 1; + d.insert("m", 10); + println!("{} {}", d["n"], d["m"]); +} +"#, + "6 10\n", + ); +} + +#[test] +fn test_dictidx_1_mixed_chains() { + assert_matches_rust( + r#" +fun main() { + let mut m = {"a": {"b": [1]}} + m["a"]["b"].push(3) + m["a"]["b"][0] += 40 + let mut v = [{"x": 1}] + v[0]["x"] += 10 + println("{:?} {}", m["a"]["b"], v[0]["x"]) +} +"#, + r#" +use std::collections::HashMap; +fn main() { + let mut inner: HashMap<&str, Vec> = HashMap::new(); + inner.insert("b", vec![1]); + let mut m: HashMap<&str, HashMap<&str, Vec>> = HashMap::new(); + m.insert("a", inner); + m.get_mut("a").unwrap().get_mut("b").unwrap().push(3); + m.get_mut("a").unwrap().get_mut("b").unwrap()[0] += 40; + let mut x: HashMap<&str, i64> = HashMap::new(); + x.insert("x", 1); + let mut v = vec![x]; + *v[0].get_mut("x").unwrap() += 10; + println!("{:?} {}", m["a"]["b"], v[0]["x"]); +} +"#, + "[41, 3] 11\n", + ); +} diff --git a/tests/g2b_runfix_3.rs b/tests/g2b_runfix_3.rs new file mode 100644 index 000000000..c1451c30a --- /dev/null +++ b/tests/g2b_runfix_3.rs @@ -0,0 +1,308 @@ +//! RHLGA-1 G2b run fixes, part 3. +//! +//! - FLOATDISP-1: `ruchy run` printed a float under `{}` with Debug text +//! (`3.0`, `1000000000000000000000.0`, `-0.0`); Rust's f64 Display prints +//! `3`, `1000000000000000000000`, `-0`. A bare `println(x)` transpiles to +//! `println!("{:?}", x)`, so it keeps Rust's Debug text (`3.0`, `1e21`). +//! - TMPLEAK-1: `property-tests`, `fuzz` and `notebook` removed the compiled +//! `ruchy_temp_bin_*` binary only on the success path; an error returned +//! with `?` left it in the temp dir. +//! +//! Each FLOATDISP case states the output Rust prints for the same program; a +//! hand-written Rust reference is compiled with rustc to prove it. + +use assert_cmd::Command; +use std::path::Path; +use std::time::Duration; + +fn stdout_of(mut cmd: Command, what: &str) -> String { + let out = cmd.output().expect("spawn command"); + assert!( + out.status.success(), + "{what} failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8(out.stdout).expect("utf8 stdout") +} + +fn write_source(dir: &Path, src: &str) -> std::path::PathBuf { + let file = dir.join("prog.ruchy"); + std::fs::write(&file, src).expect("write ruchy source"); + file +} + +fn ruchy() -> Command { + Command::cargo_bin("ruchy").expect("ruchy binary is built for tests") +} + +fn ruchy_run(dir: &Path, src: &str) -> String { + let mut cmd = ruchy(); + cmd.arg("run") + .arg(write_source(dir, src)) + .timeout(Duration::from_secs(10)); + stdout_of(cmd, "ruchy run") +} + +fn rustc_run(dir: &Path, rust: &str) -> String { + let main_rs = dir.join("reference.rs"); + let bin = dir.join("reference_bin"); + std::fs::write(&main_rs, rust).expect("write reference.rs"); + let mut rustc = Command::new("rustc"); + rustc + .args(["--edition", "2021", "-A", "warnings", "-o"]) + .arg(&bin) + .arg(&main_rs) + .timeout(Duration::from_secs(120)); + stdout_of(rustc, "rustc on the Rust reference"); + let mut run = Command::new(&bin); + run.timeout(Duration::from_secs(10)); + stdout_of(run, "Rust reference binary") +} + +/// Rust prints `expected` for `rust`, and `ruchy run` prints it for `ruchy`. +fn assert_matches_rust(ruchy_src: &str, rust: &str, expected: &str) { + let dir = tempfile::tempdir().expect("tempdir"); + assert_eq!(rustc_run(dir.path(), rust), expected, "Rust reference"); + assert_eq!(ruchy_run(dir.path(), ruchy_src), expected, "ruchy run"); +} + +// ------------------------------------------------------------- FLOATDISP-1 + +const BRIEF_CASE: &str = r#" +fun main() { + let x = 3.0 + println("{} {}", x, 2.5) + println(x) +} +"#; + +#[test] +fn test_floatdisp_1_brief_case_display_and_bare_println() { + assert_matches_rust( + BRIEF_CASE, + r#" +fn main() { + let x = 3.0f64; + println!("{} {}", x, 2.5); + println!("{:?}", x); +} +"#, + "3 2.5\n3.0\n", + ); +} + +#[test] +fn test_floatdisp_1_display_special_values() { + assert_matches_rust( + r#" +fun main() { + let a = 1e21 + let b = 0.0 / 0.0 + let c = 1.0 / 0.0 + let d = -0.0 + let e = 1e-7 + let g = 0.1 + 0.2 + println("{} {} {} {} {} {} {}", a, b, c, d, e, g, -c) +} +"#, + r#" +fn main() { + let a = 1e21f64; + let b = 0.0f64 / 0.0; + let c = 1.0f64 / 0.0; + let d = -0.0f64; + let e = 1e-7f64; + let g = 0.1f64 + 0.2; + println!("{} {} {} {} {} {} {}", a, b, c, d, e, g, -c); +} +"#, + "1000000000000000000000 NaN inf -0 0.0000001 0.30000000000000004 -inf\n", + ); +} + +#[test] +fn test_floatdisp_1_debug_special_values_unchanged() { + assert_matches_rust( + r#" +fun main() { + let a = 1e21 + let d = -0.0 + let e = 1e-7 + println("{:?} {:?} {:?} {:?} {:?}", 3.0, a, d, e, 2.5) +} +"#, + r#" +fn main() { + let a = 1e21f64; + let d = -0.0f64; + let e = 1e-7f64; + println!("{:?} {:?} {:?} {:?} {:?}", 3.0f64, a, d, e, 2.5f64); +} +"#, + "3.0 1e21 -0.0 1e-7 2.5\n", + ); +} + +#[test] +fn test_floatdisp_1_bare_println_is_debug() { + assert_matches_rust( + r#" +fun main() { + let a = 1e21 + let e = 1e-7 + let d = -0.0 + println(a) + println(e) + println(d) + println(3.0) +} +"#, + r#" +fn main() { + let a = 1e21f64; + let e = 1e-7f64; + let d = -0.0f64; + println!("{:?}", a); + println!("{:?}", e); + println!("{:?}", d); + println!("{:?}", 3f64); +} +"#, + "1e21\n1e-7\n-0.0\n3.0\n", + ); +} + +#[test] +fn test_floatdisp_1_width_sign_and_alignment() { + assert_matches_rust( + r#" +fun main() { + let x = 3.0 + println("[{:>5}] [{:+}] [{:<4}] [{:^5}] [{:05}]", x, x, x, x, -x) +} +"#, + r#" +fn main() { + let x = 3.0f64; + println!("[{:>5}] [{:+}] [{:<4}] [{:^5}] [{:05}]", x, x, x, x, -x); +} +"#, + "[ 3] [+3] [3 ] [ 3 ] [-0003]\n", + ); +} + +#[test] +fn test_floatdisp_1_format_fn_and_fstring() { + assert_matches_rust( + r#" +fun main() { + let x = 3.0 + println(format("{}", x)) + println(f"{x} {x:?} {x:>4}") + println(x.to_string()) +} +"#, + r#" +fn main() { + let x = 3.0f64; + println!("{}", format!("{}", x)); + println!("{} {:?} {:>4}", x, x, x); + println!("{}", x.to_string()); +} +"#, + "3\n3 3.0 3\n3\n", + ); +} + +#[test] +fn test_floatdisp_1_run_matches_compiled_binary() { + let dir = tempfile::tempdir().expect("tempdir"); + let src = write_source(dir.path(), BRIEF_CASE); + let bin = dir.path().join("prog_bin"); + let mut compile = ruchy(); + compile + .arg("compile") + .arg(&src) + .arg("-o") + .arg(&bin) + .timeout(Duration::from_secs(120)); + stdout_of(compile, "ruchy compile"); + let mut run = Command::new(&bin); + run.timeout(Duration::from_secs(10)); + let compiled = stdout_of(run, "compiled binary"); + assert_eq!(compiled, "3 2.5\n3.0\n", "compiled binary"); + assert_eq!(ruchy_run(dir.path(), BRIEF_CASE), compiled, "ruchy run"); +} + +// --------------------------------------------------------------- TMPLEAK-1 + +/// The compiled program replaces its own executable with a text file, so the +/// next execution fails with "Permission denied" and the handler returns `?`. +const SELF_REPLACING: &str = r#" +fun main() { + let exe = std::env::current_exe().unwrap() + std::fs::remove_file(&exe).unwrap() + std::fs::write(&exe, "not a binary").unwrap() +} +"#; + +const PANICKING: &str = r#" +fun main() { + panic("boom") +} +"#; + +fn leaked_binaries(tmp: &Path) -> Vec { + std::fs::read_dir(tmp) + .expect("read temp dir") + .map(|e| { + e.expect("dir entry") + .file_name() + .to_string_lossy() + .into_owned() + }) + .filter(|name| name.starts_with("ruchy_temp_bin_")) + .collect() +} + +/// Runs `ruchy prog.ruchy` with TMPDIR set to a fresh directory, +/// asserts the command fails, and returns the leaked temp binaries. +fn leaks_after_failing(args: &[&str], src: &str) -> Vec { + let dir = tempfile::tempdir().expect("tempdir"); + let tmp = dir.path().join("tmp"); + std::fs::create_dir(&tmp).expect("create TMPDIR"); + let file = write_source(dir.path(), src); + let mut cmd = ruchy(); + cmd.args(&args[..1]) + .arg(&file) + .args(&args[1..]) + .env("TMPDIR", &tmp) + .timeout(Duration::from_secs(180)); + let out = cmd.output().expect("spawn ruchy"); + assert!(!out.status.success(), "the {} run must fail", args[0]); + leaked_binaries(&tmp) +} + +#[test] +fn test_tmpleak_1_property_tests_error_path_removes_binary() { + let leaked = leaks_after_failing(&["property-tests", "--cases", "3"], SELF_REPLACING); + assert!(leaked.is_empty(), "leaked temp binaries: {leaked:?}"); +} + +#[test] +fn test_tmpleak_1_fuzz_error_path_removes_binary() { + let leaked = leaks_after_failing(&["fuzz", "--iterations", "3"], SELF_REPLACING); + assert!(leaked.is_empty(), "leaked temp binaries: {leaked:?}"); +} + +#[test] +fn test_tmpleak_1_property_tests_panicking_program_removes_binary() { + let leaked = leaks_after_failing(&["property-tests", "--cases", "3"], PANICKING); + assert!(leaked.is_empty(), "leaked temp binaries: {leaked:?}"); +} + +#[test] +fn test_tmpleak_1_notebook_failing_program_removes_binary() { + let leaked = leaks_after_failing(&["notebook"], PANICKING); + assert!(leaked.is_empty(), "leaked temp binaries: {leaked:?}"); +} diff --git a/tests/g2b_runfix_4.rs b/tests/g2b_runfix_4.rs new file mode 100644 index 000000000..477b359cc --- /dev/null +++ b/tests/g2b_runfix_4.rs @@ -0,0 +1,232 @@ +//! RHLGA-1 G2b run fixes, part 4. +//! +//! - IDXEVAL1-1: `ruchy run` evaluated the index expression of a compound +//! assignment (`v[next()] += 1`) or a nested-index assignment +//! (`m[f()][1] = 9`) twice: once to read the place and once to write it +//! back. Compiled code evaluates it once. +//! - FMTTEMPLATE-1: `ruchy run` treated a non-literal first argument of +//! `println(s, 1)` as a format template; the transpiler treats only a +//! string literal as one and prints every argument separated by spaces. +//! - FMTEXTRA-1: the format engine ignored an extra positional argument +//! (`println("{}", 1, 2)`); rustc rejects an argument the template never +//! uses, so `ruchy run` now reports an error. +//! +//! Every accepted case compares `ruchy run` with the binary `ruchy compile` +//! builds from the same source. + +use assert_cmd::Command; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +fn write_source(dir: &Path, src: &str) -> PathBuf { + let file = dir.join("prog.ruchy"); + std::fs::write(&file, src).expect("write ruchy source"); + file +} + +fn ruchy() -> Command { + Command::cargo_bin("ruchy").expect("ruchy binary is built for tests") +} + +fn stdout_of(mut cmd: Command, what: &str) -> String { + let out = cmd.output().expect("spawn command"); + assert!( + out.status.success(), + "{what} failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8(out.stdout).expect("utf8 stdout") +} + +fn run_cmd(dir: &Path, src: &str) -> Command { + let mut cmd = ruchy(); + cmd.arg("run") + .arg(write_source(dir, src)) + .timeout(Duration::from_secs(10)); + cmd +} + +fn compile_cmd(dir: &Path, src: &str) -> (Command, PathBuf) { + let bin = dir.join("prog_bin"); + let mut cmd = ruchy(); + cmd.arg("compile") + .arg(write_source(dir, src)) + .arg("-o") + .arg(&bin) + .timeout(Duration::from_secs(180)); + (cmd, bin) +} + +fn compiled_run(dir: &Path, src: &str) -> String { + let (cmd, bin) = compile_cmd(dir, src); + stdout_of(cmd, "ruchy compile"); + let mut run = Command::new(&bin); + run.timeout(Duration::from_secs(10)); + stdout_of(run, "compiled binary") +} + +/// The compiled binary prints `expected`, and `ruchy run` prints it too. +fn assert_run_matches_compiled(src: &str, expected: &str) { + let dir = tempfile::tempdir().expect("tempdir"); + assert_eq!(compiled_run(dir.path(), src), expected, "compiled binary"); + assert_eq!( + stdout_of(run_cmd(dir.path(), src), "ruchy run"), + expected, + "ruchy run" + ); +} + +/// `ruchy run` prints `expected`: the output Rust semantics give. Used where +/// the transpiler cannot build the program yet. +fn assert_run_prints(src: &str, expected: &str) { + let dir = tempfile::tempdir().expect("tempdir"); + assert_eq!( + stdout_of(run_cmd(dir.path(), src), "ruchy run"), + expected, + "ruchy run" + ); +} + +/// `ruchy compile` rejects `src`, and `ruchy run` fails with `needle` on +/// stderr. +fn assert_both_reject(src: &str, needle: &str) { + let dir = tempfile::tempdir().expect("tempdir"); + let (mut cmd, _) = compile_cmd(dir.path(), src); + cmd.assert().failure(); + let out = run_cmd(dir.path(), src).output().expect("spawn ruchy run"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(!out.status.success(), "ruchy run accepted: {stderr}"); + assert!(stderr.contains(needle), "stderr lacks {needle:?}: {stderr}"); +} + +// ------------------------------------------------------------- IDXEVAL1-1 + +const COUNTER: &str = r#" +let mut calls = 0 +fun next() -> i64 { + calls += 1 + println("index {}", calls) + 0 +} +"#; + +fn with_counter(main: &str) -> String { + format!("{COUNTER}{main}") +} + +#[test] +fn test_idxeval1_1_compound_index_evaluates_index_once() { + let src = with_counter( + r#" +fun main() { + let mut v = [10, 20] + v[next()] += 1 + v[next()] *= 3 + println("{:?} {}", v, calls) +} +"#, + ); + assert_run_matches_compiled(&src, "index 1\nindex 2\n[33, 20] 2\n"); +} + +#[test] +fn test_idxeval1_1_nested_index_assign_evaluates_index_once() { + let src = with_counter( + r#" +fun main() { + let mut m = [[1, 2], [3, 4]] + m[next()][1] = 9 + println("{:?} {}", m, calls) +} +"#, + ); + assert_run_matches_compiled(&src, "index 1\n[[1, 9], [3, 4]] 1\n"); +} + +#[test] +fn test_idxeval1_1_nested_compound_evaluates_each_index_once() { + let src = with_counter( + r#" +fun main() { + let mut m = [[1, 2], [3, 4]] + m[next()][next()] += 5 + println("{:?} {}", m, calls) +} +"#, + ); + assert_run_matches_compiled(&src, "index 1\nindex 2\n[[6, 2], [3, 4]] 2\n"); +} + +/// The transpiler emits a nested array literal as a fixed-size array, which +/// has no `push`, so this case is checked against `ruchy run` alone. +#[test] +fn test_idxeval1_1_in_place_push_on_indexed_element_evaluates_index_once() { + let src = with_counter( + r#" +fun main() { + let mut m = [[1], [2]] + m[next()].push(7) + println("{:?} {}", m, calls) +} +"#, + ); + assert_run_prints(&src, "index 1\n[[1, 7], [2]] 1\n"); +} + +// ---------------------------------------------------------- FMTTEMPLATE-1 + +#[test] +fn test_fmttemplate_1_variable_first_arg_is_not_a_template() { + assert_run_matches_compiled( + r#" +fun main() { + let s = "{}" + println(s, 1) + let t = "x={} y={}" + println(t, 2, 3) + println("a", 1) + println("{} and {}", 4, 5) +} +"#, + "{} 1\nx={} y={} 2 3\na 1\n4 and 5\n", + ); +} + +// ------------------------------------------------------------- FMTEXTRA-1 + +#[test] +fn test_fmtextra_1_extra_positional_argument_is_rejected() { + assert_both_reject( + r#" +fun main() { + println("{} {}", 1, 2, 3) +} +"#, + "argument never used", + ); +} + +#[test] +fn test_fmtextra_1_unreferenced_explicit_index_is_rejected() { + assert_both_reject( + r#" +fun main() { + println("{0} {0}", 1, 2) +} +"#, + "argument never used", + ); +} + +#[test] +fn test_fmtextra_1_explicit_indices_count_as_uses() { + assert_run_matches_compiled( + r#" +fun main() { + println("{1} {0} {}", 1, 2) + println("{} {0}", 3) +} +"#, + "2 1 1\n3 3\n", + ); +} diff --git a/tests/g2b_runfix_5.rs b/tests/g2b_runfix_5.rs new file mode 100644 index 000000000..6b31e6515 --- /dev/null +++ b/tests/g2b_runfix_5.rs @@ -0,0 +1,208 @@ +//! RHLGA-1 G2b run fixes, part 5. +//! +//! - FORLEAK-1: a `for` loop bound its variable with an assignment, so a +//! same-named outer binding was overwritten, and a destructuring pattern +//! (`for (k, v) in ...`) bound the whole element to its first name. Each +//! iteration now binds its pattern in a scope of its own. +//! - ARRREPEAT-1: arrays had no `repeat` method (Rust `slice::repeat`). +//! - FORMATVAL-1: `format`/`println` called through a variable or parameter +//! treated a non-literal first argument as a format template; only a +//! string literal is one (FMTTEMPLATE-1). +//! +//! Where the transpiler can build the program, `ruchy run` is compared with +//! the binary `ruchy compile` builds from the same source. + +use assert_cmd::Command; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +fn write_source(dir: &Path, src: &str) -> PathBuf { + let file = dir.join("prog.ruchy"); + std::fs::write(&file, src).expect("write ruchy source"); + file +} + +fn ruchy() -> Command { + Command::cargo_bin("ruchy").expect("ruchy binary is built for tests") +} + +fn stdout_of(mut cmd: Command, what: &str) -> String { + let out = cmd.output().expect("spawn command"); + assert!( + out.status.success(), + "{what} failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8(out.stdout).expect("utf8 stdout") +} + +fn run_cmd(dir: &Path, src: &str) -> Command { + let mut cmd = ruchy(); + cmd.arg("run") + .arg(write_source(dir, src)) + .timeout(Duration::from_secs(10)); + cmd +} + +fn compile_cmd(dir: &Path, src: &str) -> (Command, PathBuf) { + let bin = dir.join("prog_bin"); + let mut cmd = ruchy(); + cmd.arg("compile") + .arg(write_source(dir, src)) + .arg("-o") + .arg(&bin) + .timeout(Duration::from_secs(180)); + (cmd, bin) +} + +fn compiled_run(dir: &Path, src: &str) -> String { + let (cmd, bin) = compile_cmd(dir, src); + stdout_of(cmd, "ruchy compile"); + let mut run = Command::new(&bin); + run.timeout(Duration::from_secs(10)); + stdout_of(run, "compiled binary") +} + +/// The compiled binary prints `expected`, and `ruchy run` prints it too. +fn assert_run_matches_compiled(src: &str, expected: &str) { + let dir = tempfile::tempdir().expect("tempdir"); + assert_eq!(compiled_run(dir.path(), src), expected, "compiled binary"); + assert_eq!( + stdout_of(run_cmd(dir.path(), src), "ruchy run"), + expected, + "ruchy run" + ); +} + +/// `ruchy run` prints `expected`: the output Rust semantics give. Used where +/// the transpiler cannot build the program yet. +fn assert_run_prints(src: &str, expected: &str) { + let dir = tempfile::tempdir().expect("tempdir"); + assert_eq!( + stdout_of(run_cmd(dir.path(), src), "ruchy run"), + expected, + "ruchy run" + ); +} + +// -------------------------------------------------------------- FORLEAK-1 + +#[test] +fn test_forleak_1_outer_binding_survives_same_named_loop_var() { + assert_run_matches_compiled( + "let x = \"a\"\nfor x in [[1]] { }\nprintln(\"{}\", x)\n", + "a\n", + ); +} + +#[test] +fn test_forleak_1_outer_binding_survives_range_loop_var() { + assert_run_matches_compiled( + "let i = 10\nfor i in 0..3 { println(\"{}\", i) }\nprintln(\"{}\", i)\n", + "0\n1\n2\n10\n", + ); +} + +#[test] +fn test_forleak_1_outer_mutation_in_body_persists() { + assert_run_matches_compiled( + "let mut s = 0\nfor i in [1, 2] { s += i }\nprintln(\"{}\", s)\n", + "3\n", + ); +} + +#[test] +fn test_forleak_1_break_and_continue_still_work() { + assert_run_matches_compiled( + "let mut t = 0\nfor i in 0..10 {\n if i == 1 { continue }\n if i == 4 { break }\n t += i\n}\nprintln(\"{}\", t)\n", + "5\n", + ); +} + +#[test] +fn test_forleak_1_tuple_pattern_binds_each_name() { + assert_run_prints( + "let k = 7\nfor (k, v) in [(1, 2), (3, 4)] { println(\"{} {}\", k, v) }\nprintln(\"{}\", k)\n", + "1 2\n3 4\n7\n", + ); +} + +#[test] +fn test_forleak_1_pattern_binding_leaves_outer_second_name() { + assert_run_prints( + "let v = \"outer\"\nfor (k, v) in [(1, 2)] { println(\"{}\", k + v) }\nprintln(\"{}\", v)\n", + "3\nouter\n", + ); +} + +#[test] +fn test_forleak_1_if_let_while_let_match_do_not_leak() { + assert_run_prints( + "let y = 1\nif let Some(y) = Some(3) { println(\"{}\", y) }\nprintln(\"{}\", y)\n\ + let mut w = Some(4)\nlet q = 1\nwhile let Some(q) = w { println(\"{}\", q)\n w = None }\nprintln(\"{}\", q)\n\ + let z = 1\nmatch 5 { z => println(\"{}\", z) }\nprintln(\"{}\", z)\n", + "3\n1\n4\n1\n5\n1\n", + ); +} + +// ------------------------------------------------------------ ARRREPEAT-1 + +#[test] +fn test_arrrepeat_1_repeat_twice() { + assert_run_matches_compiled("let v = [1]\nprintln(\"{:?}\", v.repeat(2))\n", "[1, 1]\n"); +} + +#[test] +fn test_arrrepeat_1_repeat_keeps_order_and_zero_is_empty() { + assert_run_prints( + "let v = [1, 2]\nprintln(\"{:?}\", v.repeat(3))\nprintln(\"{:?}\", v.repeat(0))\nprintln(\"{:?}\", v)\n", + "[1, 2, 1, 2, 1, 2]\n[]\n[1, 2]\n", + ); +} + +#[test] +fn test_arrrepeat_1_negative_count_is_error() { + let dir = tempfile::tempdir().expect("tempdir"); + let out = run_cmd(dir.path(), "let v = [1]\nprintln(\"{:?}\", v.repeat(-1))\n") + .output() + .expect("spawn ruchy run"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(!out.status.success(), "ruchy run accepted: {stderr}"); + assert!(stderr.contains("repeat"), "stderr lacks repeat: {stderr}"); +} + +#[test] +fn test_arrrepeat_1_string_repeat_unchanged() { + assert_run_prints("println(\"{}\", \"ab\".repeat(2))\n", "abab\n"); +} + +// ------------------------------------------------------------ FORMATVAL-1 + +#[test] +fn test_formatval_1_format_value_non_literal_is_not_template() { + assert_run_prints( + "let t = \"{}\"\nlet g = format\nprintln(\"{}\", g(t, 1))\n", + "{} 1\n", + ); +} + +#[test] +fn test_formatval_1_format_value_literal_is_template() { + assert_run_prints( + "let g = format\nprintln(\"{}\", g(\"{}-{}\", 1, 2))\n", + "1-2\n", + ); +} + +#[test] +fn test_formatval_1_println_value_non_literal_is_not_template() { + assert_run_prints("let t = \"{}\"\nlet p = println\np(t, 1)\n", "{} 1\n"); +} + +#[test] +fn test_formatval_1_format_parameter_follows_literal_rule() { + assert_run_prints( + "fun h(f, t) {\n println(\"{}\", f(t, 1))\n println(\"{}\", f(\"{}!\", 2))\n}\nh(format, \"{}\")\n", + "{} 1\n2!\n", + ); +} diff --git a/tests/g2b_runfix_6.rs b/tests/g2b_runfix_6.rs new file mode 100644 index 000000000..62f1207ac --- /dev/null +++ b/tests/g2b_runfix_6.rs @@ -0,0 +1,174 @@ +//! RHLGA-1 G2b run fixes, part 6. +//! +//! - ARRCLONE-1: arrays had no `clone` method ("Unknown array method: +//! clone"), nor `to_vec`/`to_owned`; tuples, plain objects and strings had +//! no `clone` either, and strings and tuples no `to_owned`. A clone is independent of +//! its source: mutating it leaves the original unchanged. +//! +//! Where the transpiler can build the program, `ruchy run` is compared with +//! the binary `ruchy compile` builds from the same source. + +use assert_cmd::Command; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +fn write_source(dir: &Path, src: &str) -> PathBuf { + let file = dir.join("prog.ruchy"); + std::fs::write(&file, src).expect("write ruchy source"); + file +} + +fn ruchy() -> Command { + Command::cargo_bin("ruchy").expect("ruchy binary is built for tests") +} + +fn stdout_of(mut cmd: Command, what: &str) -> String { + let out = cmd.output().expect("spawn command"); + assert!( + out.status.success(), + "{what} failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8(out.stdout).expect("utf8 stdout") +} + +fn run_cmd(dir: &Path, src: &str) -> Command { + let mut cmd = ruchy(); + cmd.arg("run") + .arg(write_source(dir, src)) + .timeout(Duration::from_secs(10)); + cmd +} + +fn compile_cmd(dir: &Path, src: &str) -> (Command, PathBuf) { + let bin = dir.join("prog_bin"); + let mut cmd = ruchy(); + cmd.arg("compile") + .arg(write_source(dir, src)) + .arg("-o") + .arg(&bin) + .timeout(Duration::from_secs(180)); + (cmd, bin) +} + +fn compiled_run(dir: &Path, src: &str) -> String { + let (cmd, bin) = compile_cmd(dir, src); + stdout_of(cmd, "ruchy compile"); + let mut run = Command::new(&bin); + run.timeout(Duration::from_secs(10)); + stdout_of(run, "compiled binary") +} + +/// The compiled binary prints `expected`, and `ruchy run` prints it too. +fn assert_run_matches_compiled(src: &str, expected: &str) { + let dir = tempfile::tempdir().expect("tempdir"); + assert_eq!(compiled_run(dir.path(), src), expected, "compiled binary"); + assert_eq!( + stdout_of(run_cmd(dir.path(), src), "ruchy run"), + expected, + "ruchy run" + ); +} + +/// `ruchy run` prints `expected`: the output Rust semantics give. Used where +/// the transpiler cannot build the program yet. +fn assert_run_prints(src: &str, expected: &str) { + let dir = tempfile::tempdir().expect("tempdir"); + assert_eq!( + stdout_of(run_cmd(dir.path(), src), "ruchy run"), + expected, + "ruchy run" + ); +} + +// ------------------------------------------------------------- ARRCLONE-1 + +#[test] +fn test_arrclone_1_array_clone_repro() { + assert_run_matches_compiled( + "let a = [1]\nlet b = a.clone()\nprintln(\"{:?}\", b)\n", + "[1]\n", + ); +} + +// The transpiler builds `[1, 2]` as a fixed array, which has no `push`. +#[test] +fn test_arrclone_1_array_clone_is_independent() { + assert_run_prints( + "let a = [1, 2]\nlet mut b = a.clone()\nb.push(3)\nb[0] = 9\nprintln(\"{:?}\", a)\nprintln(\"{:?}\", b)\n", + "[1, 2]\n[9, 2, 3]\n", + ); +} + +#[test] +fn test_arrclone_1_nested_array_clone_is_independent() { + assert_run_prints( + "let a = [[1], [2]]\nlet mut b = a.clone()\nb[0] = [7]\nprintln(\"{:?}\", a)\nprintln(\"{:?}\", b)\n", + "[[1], [2]]\n[[7], [2]]\n", + ); +} + +#[test] +fn test_arrclone_1_array_to_vec_and_to_owned() { + assert_run_matches_compiled( + "let a = [1, 2]\nprintln(\"{:?}\", a.to_vec())\nprintln(\"{:?}\", a.to_owned())\n", + "[1, 2]\n[1, 2]\n", + ); +} + +#[test] +fn test_arrclone_1_tuple_clone() { + assert_run_matches_compiled( + "let t = (1, \"x\")\nlet u = t.clone()\nprintln(\"{:?}\", u)\nprintln(\"{:?}\", t.to_owned())\n", + "(1, \"x\")\n(1, \"x\")\n", + ); +} + +#[test] +fn test_arrclone_1_string_clone_and_to_owned() { + assert_run_matches_compiled( + "let s = \"hi\"\nlet r = s.clone()\nprintln(\"{}\", r)\nprintln(\"{}\", s.to_owned())\n", + "hi\nhi\n", + ); +} + +#[test] +fn test_arrclone_1_object_clone_is_independent() { + assert_run_prints( + "let o = {x: 1}\nlet mut p = o.clone()\np.x = 5\nprintln(\"{:?}\", o.x)\nprintln(\"{:?}\", p.x)\n", + "1\n5\n", + ); +} + +// ------------------------------------------------------------- LONELIT-1 + +// A lone string literal passed to println/print is printed verbatim by the +// compiled binary (the transpiler escapes its braces); `ruchy run` agrees. +#[test] +fn test_lonelit_1_lone_println_literal_is_verbatim() { + assert_run_matches_compiled( + "println(\"{}\")\nprintln(\"x{{\")\nprintln(\"a}\")\n", + "{}\nx{{\na}\n", + ); +} + +#[test] +fn test_lonelit_1_lone_print_literal_is_verbatim() { + assert_run_matches_compiled("print(\"{{}}\")\nprintln(\"\")\n", "{{}}\n"); +} + +#[test] +fn test_lonelit_1_lone_literal_does_not_capture_names() { + assert_run_matches_compiled("let x = 1\nprintln(\"{x}\")\n", "{x}\n"); +} + +// `format` with a lone literal is `format!(lit)`: still a template. +#[test] +fn test_lonelit_1_lone_format_literal_is_template() { + assert_run_matches_compiled("let s = format(\"x{{\")\nprintln(s)\n", "x{\n"); +} + +#[test] +fn test_lonelit_1_literal_with_args_is_template() { + assert_run_matches_compiled("println(\"{}!\", 1)\nprintln(\"{{{}}}\", 2)\n", "1!\n{2}\n"); +} diff --git a/tests/g2b_s2_cli.rs b/tests/g2b_s2_cli.rs new file mode 100644 index 000000000..4f1890e34 --- /dev/null +++ b/tests/g2b_s2_cli.rs @@ -0,0 +1,205 @@ +#![allow(missing_docs)] +//! G2B-S2 (RHLGA-1): no CLI verb may silently succeed without doing its job. +//! +//! The command router used to end in `_ => { eprintln!("Command not yet +//! implemented"); Ok(()) }`, so any `Commands` variant it did not route (e.g. +//! `prove`) printed that line and exited 0. These tests walk every verb that +//! `ruchy --help` lists (recursing into nested command groups): each verb's +//! `--help` must exit 0 without the stub line, and each verb that is safe to run +//! offline on a trivial file is run on one: it must either work or exit non-zero, +//! and a verb still running at the timeout fails. Servers, REPLs, network and +//! package verbs are not run for real, so the test is hermetic. + +use assert_cmd::Command; +use std::path::Path; +use std::time::Duration; +use tempfile::TempDir; + +const STUB_MARKER: &str = "not yet implemented"; +const TRIVIAL_SOURCE: &str = "let x = 42\nprintln(x)\n"; +const PER_VERB_TIMEOUT: Duration = Duration::from_secs(30); + +fn ruchy_cmd() -> Command { + assert_cmd::cargo::cargo_bin_cmd!("ruchy") +} + +/// Subcommand names from the `Commands:` section of a clap help text. +fn subcommands_in_help(help: &str) -> Vec { + help.lines() + .skip_while(|line| line.trim() != "Commands:") + .skip(1) + .take_while(|line| line.starts_with(" ")) + .filter_map(|line| line.split_whitespace().next()) + .filter(|name| *name != "help") + .map(str::to_string) + .collect() +} + +fn help_of(path: &[String]) -> String { + let output = ruchy_cmd() + .args(path) + .arg("--help") + .output() + .expect("run ruchy --help"); + assert!(output.status.success(), "`ruchy {path:?} --help` failed"); + String::from_utf8_lossy(&output.stdout).into_owned() +} + +/// Every leaf verb path, e.g. `["prove"]`, `["infra", "destroy"]`. +fn leaf_verbs(prefix: &[String]) -> Vec> { + let children = subcommands_in_help(&help_of(prefix)); + if children.is_empty() { + return vec![prefix.to_vec()]; + } + children + .into_iter() + .flat_map(|child| { + let mut path = prefix.to_vec(); + path.push(child); + leaf_verbs(&path) + }) + .collect() +} + +/// Verbs that run offline on a trivial file, without servers, network, a +/// registry or a REPL; only these are run for real. +const SAFE_OFFLINE_VERBS: [&str; 11] = [ + "parse", + "check", + "transpile", + "ast", + "lint", + "fmt", + "score", + "tier", + "provability", + "prove", + "explain", +]; + +/// Combined stdout and stderr of a finished command. +fn output_text(output: &std::process::Output) -> String { + format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ) +} + +/// `Some(report)` when `ruchy --help` fails or prints the stub marker. +fn broken_help(verb: &[String]) -> Option { + let output = ruchy_cmd() + .args(verb) + .arg("--help") + .timeout(PER_VERB_TIMEOUT) + .output(); + let Ok(output) = output else { + return Some(format!("`ruchy {} --help` timed out", verb.join(" "))); + }; + let stub = output_text(&output).to_lowercase().contains(STUB_MARKER); + (!output.status.success() || stub) + .then(|| format!("`ruchy {} --help` failed or is a stub", verb.join(" "))) +} + +/// `None` when the verb works (exit 0 without the stub marker) or fails +/// loudly (non-zero exit). `Some(report)` when it exits 0 while printing the +/// stub marker, or is still running at the timeout. +fn silent_stub(verb: &[String], dir: &Path) -> Option { + let output = ruchy_cmd() + .current_dir(dir) + .args(verb) + .arg("main.ruchy") + .write_stdin("") + .timeout(PER_VERB_TIMEOUT) + .output(); + let Ok(output) = output else { + return Some(format!("`ruchy {}` timed out", verb.join(" "))); + }; + let is_stub = + output.status.success() && output_text(&output).to_lowercase().contains(STUB_MARKER); + is_stub.then(|| { + format!( + "`ruchy {}` exited 0 printing {STUB_MARKER:?}", + verb.join(" ") + ) + }) +} + +fn scratch_dir() -> TempDir { + let dir = TempDir::new().expect("tempdir"); + std::fs::write(dir.path().join("main.ruchy"), TRIVIAL_SOURCE).expect("write main.ruchy"); + dir +} + +#[test] +fn test_g2b_s2_help_lists_the_expected_verbs() { + let verbs = subcommands_in_help(&help_of(&[])); + for expected in ["prove", "infra", "serve", "run", "transpile"] { + assert!( + verbs.iter().any(|v| v == expected), + "`ruchy --help` should list `{expected}`: {verbs:?}" + ); + } +} + +#[test] +fn test_g2b_s2_every_verb_has_a_working_help() { + let verbs = leaf_verbs(&[]); + assert!( + verbs.len() > 40, + "expected the full verb tree, got {verbs:?}" + ); + let failures: Vec = verbs.iter().filter_map(|verb| broken_help(verb)).collect(); + assert!(failures.is_empty(), "broken help:\n{}", failures.join("\n")); +} + +#[test] +fn test_g2b_s2_no_safe_verb_silently_succeeds_as_a_stub() { + let verbs = leaf_verbs(&[]); + let safe: Vec> = SAFE_OFFLINE_VERBS + .iter() + .map(|name| vec![(*name).to_string()]) + .collect(); + for verb in &safe { + assert!(verbs.contains(verb), "`ruchy --help` should list {verb:?}"); + } + let failures: Vec = safe + .iter() + .filter_map(|verb| silent_stub(verb, scratch_dir().path())) + .collect(); + assert!( + failures.is_empty(), + "silent stubs:\n{}", + failures.join("\n") + ); +} + +#[test] +fn test_g2b_s2_prove_check_is_routed_to_the_prover() { + let dir = scratch_dir(); + let output = ruchy_cmd() + .current_dir(dir.path()) + .args(["prove", "main.ruchy", "--check"]) + .write_stdin("") + .timeout(PER_VERB_TIMEOUT) + .output() + .expect("ruchy prove"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + !stderr.to_lowercase().contains(STUB_MARKER), + "prove must reach the prover, got stderr: {stderr}" + ); + assert_eq!(output.status.code(), Some(0), "stderr: {stderr}"); +} + +#[test] +fn test_g2b_s2_prove_missing_file_fails() { + let dir = TempDir::new().expect("tempdir"); + ruchy_cmd() + .current_dir(dir.path()) + .args(["prove", "missing.ruchy", "--check"]) + .write_stdin("") + .timeout(PER_VERB_TIMEOUT) + .assert() + .failure(); +} diff --git a/tests/g2b_s3_misc.rs b/tests/g2b_s3_misc.rs new file mode 100644 index 000000000..c7c7d9be9 --- /dev/null +++ b/tests/g2b_s3_misc.rs @@ -0,0 +1,181 @@ +#![allow(missing_docs)] +//! RHLGA-1 (G2b S3): parser/transpiler defects checked end to end. +//! +//! Each program is run by the interpreter (`ruchy run`) and transpiled, +//! compiled with rustc and executed; both must print the expected output. +//! +//! - PARENCALL-1: `(obj.f)(3)` calls a closure stored in a field. The parser +//! gave FieldAccess nodes a 0..0 span, so the same-line check for a postfix +//! call failed and `(h.f)(3)` was split into `h.f; 3`. +//! - NESTEDMOD-1: `pub mod b { .. }` inside `mod a { .. }` and `pub struct` +//! inside a module. + +#![allow(clippy::expect_used)] +#![allow(clippy::unwrap_used)] + +use ruchy::frontend::ast::{Expr, ExprKind}; +use ruchy::{Parser, Transpiler}; +use std::process::Command; + +fn scratch_dir(name: &str) -> tempfile::TempDir { + tempfile::Builder::new() + .prefix(&format!("g2b_s3_{name}_")) + .tempdir() + .expect("scratch dir") +} + +fn compile_and_run(rust: &str, name: &str) -> String { + let dir = scratch_dir(name); + let src = dir.path().join("main.rs"); + let bin = dir.path().join("main"); + std::fs::write(&src, rust).expect("write rust"); + let out = Command::new("rustc") + .args(["--edition", "2021", "-A", "warnings", "-o"]) + .arg(&bin) + .arg(&src) + .output() + .expect("rustc runs"); + assert!( + out.status.success(), + "rustc rejected the transpiled program:\n{}\n--- rust ---\n{rust}", + String::from_utf8_lossy(&out.stderr) + ); + let run = Command::new(&bin).output().expect("binary runs"); + assert!(run.status.success(), "binary failed: {run:?}"); + String::from_utf8_lossy(&run.stdout).into_owned() +} + +fn ruchy_run(code: &str, name: &str) -> String { + let dir = scratch_dir(name); + let file = dir.path().join("prog.ruchy"); + std::fs::write(&file, code).expect("write ruchy"); + let out = Command::new(env!("CARGO_BIN_EXE_ruchy")) + .arg("run") + .arg(&file) + .output() + .expect("ruchy runs"); + assert!(out.status.success(), "ruchy run failed: {out:?}"); + String::from_utf8_lossy(&out.stdout).into_owned() +} + +fn parse(code: &str) -> Expr { + Parser::new(code).parse().expect("parses") +} + +/// Interpreter and transpiled binary both print `expected`. +fn differential(code: &str, name: &str, expected: &str) -> String { + let rust = Transpiler::new() + .transpile_to_program(&parse(code)) + .expect("transpiles") + .to_string(); + assert_eq!(ruchy_run(code, name).trim(), expected, "interpreter"); + assert_eq!(compile_and_run(&rust, name).trim(), expected, "{rust}"); + rust +} + +// ===== PARENCALL-1 ===== + +const PARENCALL_IN_MAIN: &str = "struct H { f: fn(i32) -> i32 }\n\ +fun main() {\n let h = H { f: |x| x * 2 }\n let r = (h.f)(3)\n println(r)\n}\n"; + +#[test] +fn test_parencall_1_01_field_closure_call_in_block() { + differential(PARENCALL_IN_MAIN, "parencall_block", "6"); +} + +#[test] +fn test_parencall_1_02_field_closure_call_as_argument() { + let code = "struct H { f: fn(i32) -> i32 }\n\ +fun main() {\n let h = H { f: |x| x * 2 }\n println((h.f)(3))\n}\n"; + differential(code, "parencall_arg", "6"); +} + +#[test] +fn test_parencall_1_03_top_level_after_let() { + let code = "let h = { f: |x| x * 2 }\nprintln((h.f)(3))\n"; + assert_eq!(ruchy_run(code, "parencall_top").trim(), "6"); +} + +/// The parsed `let r = (h.f)(3)` binds a Call whose callee is the field. +#[test] +fn test_parencall_1_04_ast_is_a_call_on_the_field() { + let ast = parse("let h = 1\nlet r = (h.f)(3)\nr"); + let text = format!("{ast:?}"); + assert!( + text.contains("Call { func: Expr { kind: FieldAccess"), + "not a call on the field: {text}" + ); +} + +/// A field access carries the source span of `receiver.field`. +#[test] +fn test_parencall_1_05_field_access_span_covers_source() { + let src = "obj.field"; + let ast = parse(src); + assert!(matches!(ast.kind, ExprKind::FieldAccess { .. }), "{ast:?}"); + assert_eq!((ast.span.start, ast.span.end), (0, src.len())); + let tuple = parse("t.0"); + assert_eq!((tuple.span.start, tuple.span.end), (0, 3), "{tuple:?}"); +} + +/// A parenthesised field on one line and a parenthesised expression on the +/// next stay two statements: the postfix call still requires the same line. +#[test] +fn test_parencall_1_06_paren_on_next_line_is_not_a_call() { + let ast = parse("let h = 1\n(h.f)\n(3)"); + let text = format!("{ast:?}"); + assert!( + !text.contains("Call {"), + "split lines became a call: {text}" + ); +} + +// ===== NESTEDMOD-1 ===== + +const NESTED_MOD: &str = "mod a {\n pub fun g() -> i32 { 1 }\n pub mod b {\n \ +pub fun f() -> i32 { 41 }\n }\n}\n\ +fun main() {\n println(a::b::f() + a::g())\n}\n"; + +#[test] +fn test_nestedmod_1_01_nested_pub_mod_path_call() { + let rust = differential(NESTED_MOD, "nested_mod", "42"); + assert!(rust.contains("pub mod b"), "{rust}"); +} + +/// `pub struct` inside a module parses (it was refused: "'pub' can only be +/// used with function declarations ..."), and the transpiled module keeps the +/// struct and its `pub` field public. The interpreter run avoids constructing +/// the struct inside the module (see the receipt: runtime struct lookup). +#[test] +fn test_nestedmod_1_02_pub_struct_inside_mod() { + let code = "mod shapes {\n pub struct P { pub x: i32 }\n \ +pub fun make() -> P { P { x: 5 } }\n}\n\ +fun main() {\n let p = shapes::make()\n println(p.x)\n}\n"; + let rust = Transpiler::new() + .transpile_to_program(&parse(code)) + .expect("transpiles") + .to_string(); + assert!(rust.contains("pub struct P"), "{rust}"); + assert_eq!(compile_and_run(&rust, "pub_struct").trim(), "5", "{rust}"); + let interp = "mod shapes {\n pub struct P { pub x: i32 }\n \ +pub fun five() -> i32 { 5 }\n}\n\ +fun main() {\n println(shapes::five())\n}\n"; + assert_eq!(ruchy_run(interp, "pub_struct_run").trim(), "5"); +} + +#[test] +fn test_nestedmod_1_03_private_nested_mod_used_inside_parent() { + let code = "mod a {\n mod inner {\n pub fun v() -> i32 { 7 }\n }\n \ +pub fun g() -> i32 { inner::v() * 2 }\n}\n\ +fun main() {\n println(a::g())\n}\n"; + let rust = differential(code, "private_nested", "14"); + assert!(!rust.contains("pub mod inner"), "{rust}"); +} + +#[test] +fn test_nestedmod_1_04_three_levels() { + let code = "mod a {\n pub mod b {\n pub mod c {\n \ +pub fun f() -> i32 { 3 }\n }\n }\n}\n\ +fun main() {\n println(a::b::c::f())\n}\n"; + differential(code, "three_levels", "3"); +} diff --git a/tests/g2b_t2a_receiver_and_paths.rs b/tests/g2b_t2a_receiver_and_paths.rs new file mode 100644 index 000000000..88f9b67fc --- /dev/null +++ b/tests/g2b_t2a_receiver_and_paths.rs @@ -0,0 +1,152 @@ +//! G2B-T5 (RHLGA-1): a `::` callee is a path, a `.` callee is a field. +//! +//! The parser records `a::b` as a FieldAccess marked with `Expr::is_path_access`, +//! so the transpiler emits `a::b(..)` for a path callee and `(obj.f)(..)` for a +//! field callee (a closure/fn pointer stored in a struct field). Each program is +//! transpiled, compiled with rustc and run; its output is compared with +//! `ruchy run` where the interpreter accepts the program. + +#![allow(clippy::expect_used)] +#![allow(clippy::unwrap_used)] + +use ruchy::frontend::ast::Expr; +use ruchy::{Parser, Transpiler}; +use std::path::Path; +use std::process::Command; + +/// A scratch directory for one program, removed when the returned guard drops. +fn scratch_dir(name: &str) -> tempfile::TempDir { + tempfile::Builder::new() + .prefix(&format!("g2b_t2a_{name}_")) + .tempdir() + .expect("scratch dir") +} + +fn compile_and_run(rust: &str, name: &str) -> String { + let dir = scratch_dir(name); + let src = dir.path().join("main.rs"); + let bin = dir.path().join("main"); + std::fs::write(&src, rust).expect("write rust"); + let out = Command::new("rustc") + .args(["--edition", "2021", "-A", "warnings", "-o"]) + .arg(&bin) + .arg(&src) + .output() + .expect("rustc runs"); + assert!( + out.status.success(), + "rustc rejected the transpiled program:\n{}\n--- rust ---\n{rust}", + String::from_utf8_lossy(&out.stderr) + ); + let run = Command::new(&bin).output().expect("binary runs"); + assert!(run.status.success(), "binary failed: {run:?}"); + String::from_utf8_lossy(&run.stdout).into_owned() +} + +fn transpile(ast: &Expr) -> String { + Transpiler::new() + .transpile_to_program(ast) + .expect("transpiles") + .to_string() +} + +fn ruchy_run(code: &str, name: &str) -> String { + let dir = scratch_dir(name); + let file = dir.path().join("prog.ruchy"); + std::fs::write(&file, code).expect("write ruchy"); + let out = Command::new(env!("CARGO_BIN_EXE_ruchy")) + .arg("run") + .arg(Path::new(&file)) + .output() + .expect("ruchy runs"); + assert!(out.status.success(), "ruchy run failed: {out:?}"); + String::from_utf8_lossy(&out.stdout).into_owned() +} + +/// Transpile, compile, run, and compare with the interpreter. +fn differential(code: &str, name: &str, expected: &str) -> String { + let ast = Parser::new(code).parse().expect("parses"); + let rust = transpile(&ast); + assert_eq!(compile_and_run(&rust, name).trim(), expected, "{rust}"); + assert_eq!(ruchy_run(code, name).trim(), expected); + rust +} + +#[test] +fn test_g2b_t5_01_module_path_call() { + let code = r#" +mod helpers { + pub fun double(x: i32) -> i32 { x * 2 } +} +fun main() { + println!("{}", helpers::double(4)) +} +"#; + let rust = differential(code, "module", "8"); + assert!(rust.contains("helpers :: double"), "{rust}"); +} + +/// A three-segment path `std::cmp::max(..)`, compiled with rustc. The +/// interpreter has no `std::cmp` (`Object has no field named 'cmp'`), so this +/// case is transpiler-only. +#[test] +fn test_g2b_t5_02_nested_path_call() { + let code = r#" +fun main() { + println!("{}", std::cmp::max(3, 7)) +} +"#; + let ast = Parser::new(code).parse().expect("parses"); + let rust = transpile(&ast); + assert!(rust.contains("std :: cmp :: max (3"), "{rust}"); + assert_eq!(compile_and_run(&rust, "nested").trim(), "7", "{rust}"); +} + +#[test] +fn test_g2b_t5_03_associated_function_call() { + let code = r#" +struct Point { x: i32, y: i32 } +impl Point { + pub fun new(x: i32, y: i32) -> Point { Point { x: x, y: y } } +} +fun main() { + let p = Point::new(1, 2) + println!("{}", p.x + p.y) +} +"#; + let rust = differential(code, "assoc", "3"); + assert!(rust.contains("Point :: new"), "{rust}"); +} + +/// A field holding a fn pointer, called through `(h.f)(3)`. The callee is a +/// `.` FieldAccess (not a path), so Rust needs `(h.f)(3)`, not `h::f(3)`. +#[test] +fn test_g2b_t5_04_field_callee_is_not_a_path() { + let code = r#" +struct Holder { f: fn(i32) -> i32 } +fun double(x: i32) -> i32 { x * 2 } +fun apply(h: Holder) -> i32 { + (h.f)(3) +} +fun main() { + let h = Holder { f: double } + println!("{}", apply(h)) +} +"#; + let ast = Parser::new(code).parse().expect("parses"); + let rust = transpile(&ast); + assert!( + !rust.contains("h :: f"), + "field callee emitted as a path: {rust}" + ); + assert_eq!(compile_and_run(&rust, "field").trim(), "6", "{rust}"); +} + +/// The parser marks `a::b` (path) and leaves `a.b` (field) unmarked. +#[test] +fn test_g2b_t5_05_parser_marks_path_access() { + let path = Parser::new("helpers::double").parse().expect("parses"); + assert!(path.is_path_access(), "{path:?}"); + let field = Parser::new("helpers.double").parse().expect("parses"); + assert!(!field.is_path_access(), "{field:?}"); +} diff --git a/tests/g2b_t2b_misc.rs b/tests/g2b_t2b_misc.rs new file mode 100644 index 000000000..71a888382 --- /dev/null +++ b/tests/g2b_t2b_misc.rs @@ -0,0 +1,49 @@ +#![allow(missing_docs)] +//! RHLGA-1 (G2b T2b): miscellaneous transpile/runtime defects, checked end to end. + +use assert_cmd::Command; + +fn ruchy_cmd() -> Command { + assert_cmd::cargo::cargo_bin_cmd!("ruchy") +} + +/// Compile `src` with `ruchy compile` and return the binary's stdout. +fn compile_and_run(src: &str) -> String { + let dir = tempfile::tempdir().expect("tempdir"); + let source = dir.path().join("main.ruchy"); + let binary = dir.path().join("main_bin"); + std::fs::write(&source, src).expect("write source"); + ruchy_cmd() + .arg("compile") + .arg(&source) + .arg("-o") + .arg(&binary) + .assert() + .success(); + let out = std::process::Command::new(&binary) + .output() + .expect("run binary"); + assert!(out.status.success(), "binary failed: {out:?}"); + String::from_utf8_lossy(&out.stdout).into_owned() +} + +/// A sync `main` that awaits compiled to `.await` in a non-async fn (rustc E0728). +#[test] +fn test_rhlga_1_sync_main_awaiting_async_fn_compiles_and_runs() { + let src = "async fun fetch() -> i32 {\n 42\n}\n\nfun main() {\n let d = await fetch()\n println(d)\n}\n"; + assert_eq!(compile_and_run(src).trim(), "42"); +} + +/// `async fun main` compiled to `async fn main` (rustc E0752). +#[test] +fn test_rhlga_1_async_main_compiles_and_runs() { + let src = "async fun fetch() -> i32 {\n 40\n}\n\nasync fun main() {\n let d = await fetch()\n println(d + 2)\n}\n"; + assert_eq!(compile_and_run(src).trim(), "42"); +} + +/// A non-unit tail of an awaiting main is still printed (BOOK-COMPAT-015 semantics). +#[test] +fn test_rhlga_1_awaiting_main_prints_non_unit_tail() { + let src = "async fun fetch() -> i32 {\n 7\n}\n\nfun main() {\n let d = await fetch()\n d\n}\n"; + assert_eq!(compile_and_run(src).trim(), "7"); +} diff --git a/tests/g2b_t3_misc.rs b/tests/g2b_t3_misc.rs new file mode 100644 index 000000000..d857f59cd --- /dev/null +++ b/tests/g2b_t3_misc.rs @@ -0,0 +1,163 @@ +//! G2B-T10/T12 (RHLGA-1): range aggregations and Rust-compatible `{:?}`. +//! +//! T10: `count`/`sum`/`min`/`max` on a range receiver (`range(a, b)` or +//! `a..b`) are called on the range itself; a range has no `.iter()`. +//! T12: the interpreter renders `{:?}` exactly as rustc renders the +//! transpiled value, in `println!`, `format!` and the `println(..)` function. +//! Each program is transpiled, compiled with rustc and run; its output is +//! compared with `ruchy run` where the interpreter accepts the program. + +#![allow(clippy::expect_used)] +#![allow(clippy::unwrap_used)] + +use ruchy::{Parser, Transpiler}; +use std::process::Command; + +/// A scratch directory for one program, removed when the returned guard drops. +fn scratch_dir(name: &str) -> tempfile::TempDir { + tempfile::Builder::new() + .prefix(&format!("g2b_t3_{name}_")) + .tempdir() + .expect("scratch dir") +} + +fn transpile(code: &str) -> String { + let ast = Parser::new(code).parse().expect("parses"); + Transpiler::new() + .transpile_to_program(&ast) + .expect("transpiles") + .to_string() +} + +fn compiled_output(code: &str, name: &str) -> String { + let rust = transpile(code); + let dir = scratch_dir(name); + let src = dir.path().join("main.rs"); + let bin = dir.path().join("main"); + std::fs::write(&src, &rust).expect("write rust"); + let out = Command::new("rustc") + .args(["--edition", "2021", "-A", "warnings", "-o"]) + .arg(&bin) + .arg(&src) + .output() + .expect("rustc runs"); + assert!( + out.status.success(), + "rustc rejected the transpiled program:\n{}\n--- rust ---\n{rust}", + String::from_utf8_lossy(&out.stderr) + ); + let run = Command::new(&bin).output().expect("binary runs"); + assert!(run.status.success(), "binary failed: {run:?}"); + String::from_utf8_lossy(&run.stdout).into_owned() +} + +fn interpreted_output(code: &str, name: &str) -> String { + let dir = scratch_dir(name); + let file = dir.path().join("prog.ruchy"); + std::fs::write(&file, code).expect("write ruchy"); + let out = Command::new(env!("CARGO_BIN_EXE_ruchy")) + .arg("run") + .arg(&file) + .output() + .expect("ruchy runs"); + assert!(out.status.success(), "ruchy run failed: {out:?}"); + String::from_utf8_lossy(&out.stdout).into_owned() +} + +fn assert_same_output(code: &str, name: &str, expected: &str) { + let compiled = compiled_output(code, name); + let interpreted = interpreted_output(code, name); + assert_eq!(compiled, expected, "rustc output for:\n{code}"); + assert_eq!(interpreted, compiled, "interpreter vs rustc for:\n{code}"); +} + +// ---------------------------------------------------------------- T10 ranges + +#[test] +fn test_g2bt10_range_call_count_compiles_and_counts() { + let code = "fun main() {\n let count = range(0, 10).count()\n println(count)\n}\n"; + assert_eq!(compiled_output(code, "t10_count"), "10\n"); +} + +#[test] +fn test_g2bt10_range_call_sum_matches_interpreter() { + let code = "fun main() {\n let total = range(0, 10).sum()\n println(total)\n}\n"; + assert_same_output(code, "t10_sum", "45\n"); +} + +#[test] +fn test_g2bt10_range_literal_count_and_sum_compile() { + let code = "fun main() {\n let c = (0..10).count()\n let s = (1..=4).sum()\n println(c)\n println(s)\n}\n"; + assert_eq!(compiled_output(code, "t10_literal"), "10\n10\n"); +} + +#[test] +fn test_g2bt10_range_min_max_compile() { + let code = "fun main() {\n let lo = range(3, 9).min()\n let hi = range(3, 9).max()\n println(lo)\n println(hi)\n}\n"; + assert_eq!(compiled_output(code, "t10_minmax"), "Some(3)\nSome(8)\n"); +} + +#[test] +fn test_g2bt10_array_receiver_keeps_iter() { + let code = "fun main() {\n let total = [1, 2, 3].sum()\n println(total)\n}\n"; + assert!(transpile(code).contains(". iter () . sum")); + assert_same_output(code, "t10_array", "6\n"); +} + +// ------------------------------------------------------------- T12 {:?} Debug + +const DEBUG_TABLE: &str = r#"fun main() { + println!("{:?}", [1, 2]) + println!("{:?}", "hi") + println!("{:?}", "a\"b\n") + println!("{:?}", (1, true)) + println!("{:?}", 2.0) + println!("{:?}", 1.5) + println!("{:?}", -3) + println!("{:?}", [[1, 0], [2, 3]]) + println!("{:?}", Some(3)) + println!("{:?}", true) + println!("{:?}", [(1, "x")]) + println!("n={:?} s={:?} d={}", 7, "q", "r") + let v: Option = None + println!("{:?}", v) + let r: Result = Ok(1) + println!("{:?}", r) + println!("{:?}", 0..3) +} +"#; + +const DEBUG_EXPECTED: &str = r#"[1, 2] +"hi" +"a\"b\n" +(1, true) +2.0 +1.5 +-3 +[[1, 0], [2, 3]] +Some(3) +true +[(1, "x")] +n=7 s="q" d=r +None +Ok(1) +0..3 +"#; + +#[test] +fn test_g2bt12_println_macro_debug_matches_rustc() { + assert_same_output(DEBUG_TABLE, "t12_table", DEBUG_EXPECTED); +} + +#[test] +fn test_g2bt12_format_macro_matches_rustc() { + let code = "fun main() {\n let s = format!(\"{:?}|{}-{}\", [\"a\", \"b\"], \"x\", 1)\n println!(\"{}\", s)\n}\n"; + assert_same_output(code, "t12_format", "[\"a\", \"b\"]|x-1\n"); +} + +#[test] +fn test_g2bt12_println_function_substitutes_like_macro() { + let code = "fun main() {\n println(\"{:?}\", [1, 2])\n println(\"{} {:?} {}\", \"a\", \"b\", 3.5)\n println(\"{}+{}\", 1, 2)\n}\n"; + let expected = "[1, 2]\na \"b\" 3.5\n1+2\n"; + assert_eq!(interpreted_output(code, "t12_fn"), expected); +} diff --git a/tests/g2b_transfix_1.rs b/tests/g2b_transfix_1.rs new file mode 100644 index 000000000..6e5f8aa0a --- /dev/null +++ b/tests/g2b_transfix_1.rs @@ -0,0 +1,216 @@ +//! RHLGA-1 transpiler fixes, each checked end to end: `ruchy transpile`, +//! `ruchy compile` (rustc), run the binary, compare exact stdout. +//! +//! - PRINTLNFMT-1: a string-literal first argument containing any `{…}` +//! placeholder (`{:?}`, `{:>5}`, `{0}`, …) is the format string. Before the +//! fix only `{}` was recognised, so `println("{:?}", v)` printed `{:?} [1, 7, 3]`. +//! - INTLEN-1: a `usize` expression (`len()`/`count()`) meeting an `int` +//! let annotation, return type or known parameter type is cast (`as i64`). +//! - COUNTITER-1: `count()` on a receiver that is already an iterator +//! (`chars()`, `iter()`, …) is called directly, without another `.iter()`. +//! +//! The interpreter (`ruchy run`) must agree for the PRINTLNFMT `{:?}` cases +//! and the INTLEN cases. + +use assert_cmd::Command; +use std::path::Path; +use std::time::Duration; + +fn ruchy() -> Command { + let mut cmd = Command::cargo_bin("ruchy").expect("ruchy binary is built for tests"); + cmd.timeout(Duration::from_secs(10)); + cmd +} + +fn write_source(dir: &Path, src: &str) -> std::path::PathBuf { + let file = dir.join("prog.ruchy"); + std::fs::write(&file, src).expect("write ruchy source"); + file +} + +/// (stdout, stderr) of a command that must succeed. +fn output_of(mut cmd: Command, what: &str) -> (String, String) { + let out = cmd.output().expect("spawn command"); + let stdout = String::from_utf8(out.stdout).expect("utf8 stdout"); + let stderr = String::from_utf8(out.stderr).expect("utf8 stderr"); + assert!(out.status.success(), "{what} failed:\n{stdout}{stderr}"); + (stdout, stderr) +} + +fn transpile(src: &str) -> String { + let dir = tempfile::tempdir().expect("tempdir"); + let file = write_source(dir.path(), src); + let mut cmd = ruchy(); + cmd.arg("transpile").arg(file); + output_of(cmd, &format!("ruchy transpile of:\n{src}\n")).0 +} + +/// `ruchy compile` the source, run the binary, return (stdout, stderr). +fn compile_and_run(src: &str) -> (String, String) { + let dir = tempfile::tempdir().expect("tempdir"); + let file = write_source(dir.path(), src); + let bin = dir.path().join("prog_bin"); + let mut cmd = ruchy(); + cmd.arg("compile") + .arg(&file) + .arg("-o") + .arg(&bin) + .timeout(Duration::from_secs(90)); + output_of(cmd, &format!("ruchy compile of:\n{src}\n")); + let mut run = Command::new(&bin); + run.timeout(Duration::from_secs(30)); + output_of(run, "compiled binary") +} + +fn interpret(src: &str) -> String { + let dir = tempfile::tempdir().expect("tempdir"); + let file = write_source(dir.path(), src); + let mut cmd = ruchy(); + cmd.arg("run").arg(file); + output_of(cmd, &format!("ruchy run of:\n{src}\n")).0 +} + +fn main_with(body: &str) -> String { + format!("fun main() {{\n let v = [1, 7, 3]\n let x = 5\n{body}\n}}\n") +} + +/// Compiled stdout is `expected`; with `interp`, `ruchy run` agrees. +fn check(src: &str, expected: &str, interp: bool) { + let (stdout, _) = compile_and_run(src); + assert_eq!( + stdout, + expected, + "compiled stdout of:\n{src}\n{}", + transpile(src) + ); + if interp { + assert_eq!(interpret(src), expected, "interpreter stdout of:\n{src}"); + } +} + +// ---------------------------------------------------------------- PRINTLNFMT-1 + +#[test] +fn test_printlnfmt_1_debug_placeholder_vec() { + check(&main_with(r#" println("{:?}", v)"#), "[1, 7, 3]\n", true); +} + +#[test] +fn test_printlnfmt_1_debug_placeholder_int() { + check(&main_with(r#" println("{:?}", x)"#), "5\n", true); +} + +#[test] +fn test_printlnfmt_1_prefixed_debug_placeholder() { + check( + &main_with(r#" println("v={:?}", v)"#), + "v=[1, 7, 3]\n", + true, + ); +} + +#[test] +fn test_printlnfmt_1_display_placeholders_unchanged() { + let src = main_with( + r#" println("{}", x) + println("{} and {:?}", x, [1])"#, + ); + check(&src, "5\n5 and [1]\n", true); +} + +#[test] +fn test_printlnfmt_1_spec_placeholders_compiled() { + let src = main_with( + r#" println("{:>5}|", x) + println("{:.2}", 3.14159) + println("{0}-{0}", x) + println("{:#?}", x)"#, + ); + check(&src, " 5|\n3.14\n5-5\n5\n", false); +} + +#[test] +fn test_printlnfmt_1_print_form() { + let src = main_with( + r#" print("{:?};", v) + println("")"#, + ); + // The interpreter prints `"{:?};" [1, 7, 3]` for `print` (runtime, not transpiler). + check(&src, "[1, 7, 3];\n", false); +} + +#[test] +fn test_printlnfmt_1_eprintln_form() { + let src = main_with( + r#" eprintln("{:?}", v) + eprint("{:?}!", x)"#, + ); + let (stdout, stderr) = compile_and_run(&src); + assert_eq!(stdout, ""); + assert_eq!(stderr, "[1, 7, 3]\n5!", "{}", transpile(&src)); +} + +#[test] +fn test_printlnfmt_1_escaped_braces_are_not_placeholders() { + let rust = transpile(&main_with(r#" println("{{x}}", v)"#)); + assert!( + !rust.contains(r#"println!("{{x}}", v)"#), + "only-escape literal is not a format string:\n{rust}" + ); +} + +// ------------------------------------------------------------------- INTLEN-1 + +#[test] +fn test_intlen_1_let_annotation() { + let src = main_with( + r#" let n: int = v.len() + println(n + 1)"#, + ); + check(&src, "4\n", true); +} + +#[test] +fn test_intlen_1_let_annotation_string_len() { + let src = main_with( + r#" let s = "hello" + let n: int = s.len() + println(n * 2)"#, + ); + check(&src, "10\n", true); +} + +#[test] +fn test_intlen_1_function_return() { + let src = "fun h(xs: Vec) -> int { xs.len() }\n\ + fun main() {\n let mut w = [4, 5]\n w.push(6)\n println(h(w) + 1)\n}\n"; + check(src, "4\n", true); +} + +#[test] +fn test_intlen_1_known_parameter() { + let src = "fun g(n: int) -> int { n * 2 }\n\ + fun main() {\n let v = [1, 7, 3]\n println(g(v.len()))\n}\n"; + check(src, "6\n", true); +} + +// ----------------------------------------------------------------- COUNTITER-1 + +#[test] +fn test_countiter_1_chars_count() { + let src = main_with( + r#" let s = "hello" + println(s.chars().count())"#, + ); + check(&src, "5\n", false); +} + +#[test] +fn test_countiter_1_iter_count() { + check(&main_with(" println(v.iter().count())"), "3\n", false); +} + +#[test] +fn test_countiter_1_collection_count_still_iterates() { + check(&main_with(" println(v.count())"), "3\n", false); +} diff --git a/tests/g2b_transfix_2.rs b/tests/g2b_transfix_2.rs new file mode 100644 index 000000000..1b6e43e6b --- /dev/null +++ b/tests/g2b_transfix_2.rs @@ -0,0 +1,221 @@ +//! RHLGA-1 transpiler fixes (second batch), each checked end to end: +//! `ruchy transpile`, `ruchy compile` (rustc), run the binary, compare exact +//! stdout. +//! +//! - PRINTSTR-1: a single string-typed argument to print/println (a string +//! literal binding, `to_string()`, `format!`, a `String` parameter) prints +//! with `{}`, not `{:?}`, so the compiled program prints no quotes. +//! - FORMATFN-1: the function form `format(...)` transpiles like `format!(...)`. +//! - RETLEN-1: an explicit `return ` in an int-returning +//! function is cast, including early returns inside `if` branches. +//! - DICTIDX-1: a string key indexes a dict literal as a map (read, index +//! assignment, in-place method); `as usize` is only for integer indices. +//! +//! PRINTSTR-1 and RETLEN-1 also require `ruchy run` to agree; the FORMATFN-1 +//! and DICTIDX-1 interpreter halves are tracked separately. + +use assert_cmd::Command; +use std::path::Path; +use std::time::Duration; + +fn ruchy() -> Command { + let mut cmd = Command::cargo_bin("ruchy").expect("ruchy binary is built for tests"); + cmd.timeout(Duration::from_secs(10)); + cmd +} + +fn write_source(dir: &Path, src: &str) -> std::path::PathBuf { + let file = dir.join("prog.ruchy"); + std::fs::write(&file, src).expect("write ruchy source"); + file +} + +/// (stdout, stderr) of a command that must succeed. +fn output_of(mut cmd: Command, what: &str) -> (String, String) { + let out = cmd.output().expect("spawn command"); + let stdout = String::from_utf8(out.stdout).expect("utf8 stdout"); + let stderr = String::from_utf8(out.stderr).expect("utf8 stderr"); + assert!(out.status.success(), "{what} failed:\n{stdout}{stderr}"); + (stdout, stderr) +} + +fn transpile(src: &str) -> String { + let dir = tempfile::tempdir().expect("tempdir"); + let file = write_source(dir.path(), src); + let mut cmd = ruchy(); + cmd.arg("transpile").arg(file); + output_of(cmd, &format!("ruchy transpile of:\n{src}\n")).0 +} + +/// `ruchy compile` the source, run the binary, return (stdout, stderr). +fn compile_and_run(src: &str) -> (String, String) { + let dir = tempfile::tempdir().expect("tempdir"); + let file = write_source(dir.path(), src); + let bin = dir.path().join("prog_bin"); + let mut cmd = ruchy(); + cmd.arg("compile") + .arg(&file) + .arg("-o") + .arg(&bin) + .timeout(Duration::from_secs(90)); + output_of(cmd, &format!("ruchy compile of:\n{src}\n")); + let mut run = Command::new(&bin); + run.timeout(Duration::from_secs(30)); + output_of(run, "compiled binary") +} + +fn interpret(src: &str) -> String { + let dir = tempfile::tempdir().expect("tempdir"); + let file = write_source(dir.path(), src); + let mut cmd = ruchy(); + cmd.arg("run").arg(file); + output_of(cmd, &format!("ruchy run of:\n{src}\n")).0 +} + +fn main_with(body: &str) -> String { + format!("fun main() {{\n{body}\n}}\n") +} + +/// Compiled stdout is `expected`; with `interp`, `ruchy run` agrees. +fn check(src: &str, expected: &str, interp: bool) { + let (stdout, _) = compile_and_run(src); + assert_eq!( + stdout, + expected, + "compiled stdout of:\n{src}\n{}", + transpile(src) + ); + if interp { + assert_eq!(interpret(src), expected, "interpreter stdout of:\n{src}"); + } +} + +// ---------------------------------------------------------------- PRINTSTR-1 + +#[test] +fn test_printstr_1_literal_to_string_and_format_bindings() { + let src = main_with( + r#" let s = "hi" + let t = s.to_string() + let u = format!("{}-{}", s, 1) + println(s) + println(t) + println(u)"#, + ); + check(&src, "hi\nhi\nhi-1\n", true); +} + +#[test] +fn test_printstr_1_string_parameter() { + let src = "fun greet(name: String) {\n println(name)\n}\n\ + fun main() {\n greet(\"bob\".to_string())\n println(\"done\")\n}\n"; + check(src, "bob\ndone\n", true); +} + +#[test] +fn test_printstr_1_string_method_result() { + check( + &main_with(r#" println("a b".to_uppercase())"#), + "A B\n", + true, + ); +} + +#[test] +fn test_printstr_1_numbers_and_vecs_unchanged() { + let src = main_with( + r#" let n = 42 + let v = [1, 2] + println(n) + println(v)"#, + ); + check(&src, "42\n[1, 2]\n", true); +} + +// ---------------------------------------------------------------- FORMATFN-1 + +#[test] +fn test_formatfn_1_debug_placeholder() { + let src = main_with( + r#" let f = format("{:?}", [1, 2]) + println(f)"#, + ); + check(&src, "[1, 2]\n", false); +} + +#[test] +fn test_formatfn_1_display_placeholders() { + let src = main_with( + r#" let g = format("{}-{}", "a", 3) + println(g)"#, + ); + check(&src, "a-3\n", false); +} + +// ---------------------------------------------------------------- RETLEN-1 + +#[test] +fn test_retlen_1_explicit_return_len() { + // A String argument: an array literal passed to a `Vec` parameter is a + // separate defect (no `.to_vec()`), kept out of this case. + let src = "fun n(s: String) -> int {\n return s.len()\n}\n\ + fun main() {\n println(n(\"abc\".to_string()))\n}\n"; + check(src, "3\n", true); +} + +#[test] +fn test_retlen_1_early_return_in_if_branch() { + let src = "fun m(s: String) -> int {\n if s.len() > 2 {\n return s.len()\n }\n return 0\n}\n\ + fun main() {\n println(m(\"abc\".to_string()))\n println(m(\"a\".to_string()))\n}\n"; + check(src, "3\n0\n", true); +} + +// ---------------------------------------------------------------- DICTIDX-1 + +#[test] +fn test_dictidx_1_push_through_string_key() { + let src = main_with( + r#" let mut d = {"k": [1]} + d["k"].push(2) + println("{:?}", d["k"])"#, + ); + check(&src, "[1, 2]\n", false); +} + +#[test] +fn test_dictidx_1_read_string_key() { + let src = main_with( + r#" let d = {"a": [1, 2]} + println("{:?}", d["a"])"#, + ); + check(&src, "[1, 2]\n", false); +} + +#[test] +fn test_dictidx_1_assign_string_key() { + let src = main_with( + r#" let mut d = {"k": [1]} + d["k"] = [7, 8] + println("{:?}", d["k"])"#, + ); + check(&src, "[7, 8]\n", false); +} + +#[test] +fn test_dictidx_1_debug_print_map() { + let src = main_with( + r#" let d = {"a": [1]} + println("{:?}", d)"#, + ); + check(&src, "{\"a\": [1]}\n", false); +} + +#[test] +fn test_dictidx_1_integer_index_unchanged() { + let src = main_with( + r#" let v = [1, 2, 3] + let i = 1 + println(v[i])"#, + ); + check(&src, "2\n", true); +} diff --git a/tests/g2b_transfix_3.rs b/tests/g2b_transfix_3.rs new file mode 100644 index 000000000..27cf490b5 --- /dev/null +++ b/tests/g2b_transfix_3.rs @@ -0,0 +1,194 @@ +//! RHLGA-1 transpiler fixes (third batch), each checked end to end: +//! `ruchy transpile`, `ruchy compile` (rustc), run the binary, compare exact +//! stdout, and require `ruchy run` to print the same. +//! +//! - ARRVECARG-1: an array literal argument to a `Vec` parameter becomes a +//! `Vec` (E0308 before). +//! - MAINUNIT-1: a call to a unit-returning user function as the last +//! statement of `main` is not wrapped in `println!("{:?}", ..)` (printed +//! `()` before); a value-returning tail is still printed (BOOK-COMPAT-015). +//! - DICTTYPE-1: a dict literal whose values are all ints, floats or bools is +//! typed by those values, not stringified to `BTreeMap`. + +use assert_cmd::Command; +use std::path::Path; +use std::time::Duration; + +fn ruchy() -> Command { + let mut cmd = Command::cargo_bin("ruchy").expect("ruchy binary is built for tests"); + cmd.timeout(Duration::from_secs(10)); + cmd +} + +fn write_source(dir: &Path, src: &str) -> std::path::PathBuf { + let file = dir.join("prog.ruchy"); + std::fs::write(&file, src).expect("write ruchy source"); + file +} + +/// (stdout, stderr) of a command that must succeed. +fn output_of(mut cmd: Command, what: &str) -> (String, String) { + let out = cmd.output().expect("spawn command"); + let stdout = String::from_utf8(out.stdout).expect("utf8 stdout"); + let stderr = String::from_utf8(out.stderr).expect("utf8 stderr"); + assert!(out.status.success(), "{what} failed:\n{stdout}{stderr}"); + (stdout, stderr) +} + +fn transpile(src: &str) -> String { + let dir = tempfile::tempdir().expect("tempdir"); + let file = write_source(dir.path(), src); + let mut cmd = ruchy(); + cmd.arg("transpile").arg(file); + output_of(cmd, &format!("ruchy transpile of:\n{src}\n")).0 +} + +/// `ruchy compile` the source, run the binary, return (stdout, stderr). +fn compile_and_run(src: &str) -> (String, String) { + let dir = tempfile::tempdir().expect("tempdir"); + let file = write_source(dir.path(), src); + let bin = dir.path().join("prog_bin"); + let mut cmd = ruchy(); + cmd.arg("compile") + .arg(&file) + .arg("-o") + .arg(&bin) + .timeout(Duration::from_secs(90)); + output_of(cmd, &format!("ruchy compile of:\n{src}\n")); + let mut run = Command::new(&bin); + run.timeout(Duration::from_secs(30)); + output_of(run, "compiled binary") +} + +fn interpret(src: &str) -> String { + let dir = tempfile::tempdir().expect("tempdir"); + let file = write_source(dir.path(), src); + let mut cmd = ruchy(); + cmd.arg("run").arg(file); + output_of(cmd, &format!("ruchy run of:\n{src}\n")).0 +} + +fn main_with(body: &str) -> String { + format!("fun main() {{\n{body}\n}}\n") +} + +/// Compiled stdout is `expected`; with `interp`, `ruchy run` agrees. +fn check(src: &str, expected: &str, interp: bool) { + let (stdout, _) = compile_and_run(src); + assert_eq!( + stdout, + expected, + "compiled stdout of:\n{src}\n{}", + transpile(src) + ); + if interp { + assert_eq!(interpret(src), expected, "interpreter stdout of:\n{src}"); + } +} + +// --------------------------------------------------------------- ARRVECARG-1 + +#[test] +fn test_arrvecarg_1_array_literal_to_vec_param() { + let src = "fun n(v: Vec) -> i32 {\n v[0]\n}\n\ + fun main() {\n println(\"{}\", n([1, 2, 3]))\n}\n"; + check(src, "1\n", true); +} + +#[test] +fn test_arrvecarg_1_vec_param_iterated() { + let src = "fun total(v: Vec) -> i32 {\n let mut s = 0\n for x in v {\n s = s + x\n }\n s\n}\n\ + fun main() {\n println(\"{}\", total([4, 5, 6]))\n}\n"; + check(src, "15\n", true); +} + +#[test] +fn test_arrvecarg_1_second_vec_param() { + let src = "fun pick(i: i32, v: Vec) -> i32 {\n v[i]\n}\n\ + fun main() {\n println(\"{}\", pick(2, [7, 8, 9]))\n}\n"; + check(src, "9\n", true); +} + +// ---------------------------------------------------------------- MAINUNIT-1 + +#[test] +fn test_mainunit_1_unit_call_after_let() { + let src = "fun hello() {\n println(\"hi\")\n}\n\ + fun main() {\n let x = 1\n hello()\n}\n"; + check(src, "hi\n", true); +} + +#[test] +fn test_mainunit_1_unit_call_only_statement() { + let src = "fun hello() {\n println(\"hi\")\n}\n\ + fun main() {\n hello()\n}\n"; + check(src, "hi\n", true); +} + +#[test] +fn test_mainunit_1_unit_function_with_loop() { + let src = "fun count(n: i32) {\n for i in 0..n {\n println(\"{}\", i)\n }\n}\n\ + fun main() {\n println(\"start\")\n count(2)\n}\n"; + check(src, "start\n0\n1\n", true); +} + +#[test] +fn test_mainunit_1_value_tail_still_printed() { + // BOOK-COMPAT-015: a value-returning tail call of main is printed. + let src = "fun add(a: i32, b: i32) -> i32 {\n a + b\n}\n\ + fun main() {\n let x = 1\n add(x, 2)\n}\n"; + let (stdout, _) = compile_and_run(src); + assert_eq!(stdout, "3\n", "compiled stdout of:\n{}", transpile(src)); +} + +// ---------------------------------------------------------------- DICTTYPE-1 + +#[test] +fn test_dicttype_1_int_values_assign_and_add() { + let src = main_with( + r#" let mut d = {"a": 1, "b": 2} + d["a"] = 5 + println("{}", d["a"] + d["b"])"#, + ); + check(&src, "7\n", true); +} + +#[test] +fn test_dicttype_1_float_values() { + let src = main_with( + r#" let d = {"x": 1.5, "y": 2.25} + println("{}", d["x"] + d["y"])"#, + ); + check(&src, "3.75\n", true); +} + +#[test] +fn test_dicttype_1_bool_values() { + let src = main_with( + r#" let mut d = {"on": true, "off": false} + d["off"] = true + println("{}", d["on"] && d["off"])"#, + ); + check(&src, "true\n", true); +} + +#[test] +fn test_dicttype_1_string_values_unchanged() { + let src = main_with( + r#" let d = {"a": "x", "b": "y"} + println("{}", d["a"])"#, + ); + check(&src, "x\n", true); +} + +#[test] +fn test_dicttype_1_int_values_emit_no_string_map() { + let out = transpile(&main_with( + r#" let d = {"a": 1, "b": 2} + println("{}", d["a"])"#, + )); + assert!( + !out.contains("BTreeMap < String , String >") && !out.contains("BTreeMap"), + "int-valued dict must not be a String map:\n{out}" + ); +} diff --git a/tests/g2b_transfix_4.rs b/tests/g2b_transfix_4.rs new file mode 100644 index 000000000..8638f009e --- /dev/null +++ b/tests/g2b_transfix_4.rs @@ -0,0 +1,170 @@ +//! RHLGA-1 transpiler fixes (fourth batch), each checked end to end: +//! `ruchy transpile`, `ruchy compile` (rustc), run the binary, compare exact +//! stdout, and require `ruchy run` to print the same. +//! +//! - PRINTSTRSCOPE-1: the PRINTSTR-1 string record of a name is dropped +//! while a for-loop variable, closure parameter, or match / if-let / +//! while-let pattern re-binds it (and restored after), and `replace` / +//! `repeat` count as string results only on a string receiver. Before, a +//! shadowing binding or `v.repeat(n)` on a `Vec` printed with `{}` (E0277). + +use assert_cmd::Command; +use std::path::Path; +use std::time::Duration; + +fn ruchy() -> Command { + let mut cmd = Command::cargo_bin("ruchy").expect("ruchy binary is built for tests"); + cmd.timeout(Duration::from_secs(10)); + cmd +} + +fn write_source(dir: &Path, src: &str) -> std::path::PathBuf { + let file = dir.join("prog.ruchy"); + std::fs::write(&file, src).expect("write ruchy source"); + file +} + +/// (stdout, stderr) of a command that must succeed. +fn output_of(mut cmd: Command, what: &str) -> (String, String) { + let out = cmd.output().expect("spawn command"); + let stdout = String::from_utf8(out.stdout).expect("utf8 stdout"); + let stderr = String::from_utf8(out.stderr).expect("utf8 stderr"); + assert!(out.status.success(), "{what} failed:\n{stdout}{stderr}"); + (stdout, stderr) +} + +fn transpile(src: &str) -> String { + let dir = tempfile::tempdir().expect("tempdir"); + let file = write_source(dir.path(), src); + let mut cmd = ruchy(); + cmd.arg("transpile").arg(file); + output_of(cmd, &format!("ruchy transpile of:\n{src}\n")).0 +} + +/// `ruchy compile` the source, run the binary, return (stdout, stderr). +fn compile_and_run(src: &str) -> (String, String) { + let dir = tempfile::tempdir().expect("tempdir"); + let file = write_source(dir.path(), src); + let bin = dir.path().join("prog_bin"); + let mut cmd = ruchy(); + cmd.arg("compile") + .arg(&file) + .arg("-o") + .arg(&bin) + .timeout(Duration::from_secs(90)); + output_of(cmd, &format!("ruchy compile of:\n{src}\n")); + let mut run = Command::new(&bin); + run.timeout(Duration::from_secs(30)); + output_of(run, "compiled binary") +} + +fn interpret(src: &str) -> String { + let dir = tempfile::tempdir().expect("tempdir"); + let file = write_source(dir.path(), src); + let mut cmd = ruchy(); + cmd.arg("run").arg(file); + output_of(cmd, &format!("ruchy run of:\n{src}\n")).0 +} + +fn main_with(body: &str) -> String { + format!("fun main() {{\n{body}\n}}\n") +} + +/// Compiled stdout is `expected`; with `interp`, `ruchy run` agrees. +fn check(src: &str, expected: &str, interp: bool) { + let (stdout, _) = compile_and_run(src); + assert_eq!( + stdout, + expected, + "compiled stdout of:\n{src}\n{}", + transpile(src) + ); + if interp { + assert_eq!(interpret(src), expected, "interpreter stdout of:\n{src}"); + } +} + +// ----------------------------------------------------------- PRINTSTRSCOPE-1 + +#[test] +fn test_printstrscope_1_for_variable_shadows_string() { + let src = main_with( + r#" let x = "a" + for x in [[1]] { + println(x) + } + println(x)"#, + ); + // `ruchy run` leaks the loop variable into the enclosing scope (prints + // `[1]` twice): an interpreter defect outside this transpiler fix. + check(&src, "[1]\na\n", false); +} + +#[test] +fn test_printstrscope_1_closure_param_shadows_string() { + let src = main_with( + r#" let x = "a" + let f = |x| println(x) + f([5]) + println(x)"#, + ); + check(&src, "[5]\na\n", true); +} + +#[test] +fn test_printstrscope_1_closure_string_param_prints_text() { + let src = main_with( + r#" let f = |s: String| println(s) + f("hey".to_string()) + println("end")"#, + ); + check(&src, "hey\nend\n", true); +} + +#[test] +fn test_printstrscope_1_match_arm_binding_shadows_string() { + let src = main_with( + r#" let x = "a" + let o = Some([1, 2]) + match o { + Some(x) => println(x), + None => println("none"), + } + println(x)"#, + ); + check(&src, "[1, 2]\na\n", true); +} + +#[test] +fn test_printstrscope_1_if_let_binding_shadows_string() { + let src = main_with( + r#" let x = "a" + if let Some(x) = Some([3]) { + println(x) + } + println(x)"#, + ); + check(&src, "[3]\na\n", true); +} + +#[test] +fn test_printstrscope_1_repeat_on_vec_is_not_a_string() { + let src = main_with( + r#" let v = [1] + println(v.repeat(2))"#, + ); + // `ruchy run` has no array `repeat` ("Unknown array method: repeat"): + // an interpreter gap outside this transpiler fix. + check(&src, "[1, 1]\n", false); +} + +#[test] +fn test_printstrscope_1_string_receiver_methods_keep_text() { + let src = main_with( + r#" let s = "ab" + println(s.repeat(2)) + println("a-b".replace("-", "+")) + println(s.trim().replace("a", "z").repeat(2))"#, + ); + check(&src, "abab\na+b\nzbzb\n", true); +} diff --git a/tests/g2b_transfix_5.rs b/tests/g2b_transfix_5.rs new file mode 100644 index 000000000..056f58b31 --- /dev/null +++ b/tests/g2b_transfix_5.rs @@ -0,0 +1,207 @@ +//! RHLGA-1 transpiler fixes (fifth batch), each checked end to end: +//! `ruchy transpile`, `ruchy compile` (rustc), run the binary, compare exact +//! stdout, and require `ruchy run` to print the same. +//! +//! - NESTARRVEC-1: when an element of a nested array literal binding is +//! grown through an index (`m[0].push(9)`), the inner literals are emitted +//! as `vec![..]` too. Before, they stayed fixed-size arrays (E0599). +//! - CLOSUREUNIT-1: a call to a let-bound closure whose body is unit, as the +//! last statement of main, is not printed. Before, main printed `()`. +//! (An untyped parameter printed with `println(s)` still uses `{:?}`, so +//! `f("hi")` through `|s|` prints `"hi"`: a separate defect, also hit by +//! `fun p(s)`; these tests type the parameter or pass an integer.) + +use assert_cmd::Command; +use std::path::Path; +use std::time::Duration; + +fn ruchy() -> Command { + let mut cmd = Command::cargo_bin("ruchy").expect("ruchy binary is built for tests"); + cmd.timeout(Duration::from_secs(10)); + cmd +} + +fn write_source(dir: &Path, src: &str) -> std::path::PathBuf { + let file = dir.join("prog.ruchy"); + std::fs::write(&file, src).expect("write ruchy source"); + file +} + +/// (stdout, stderr) of a command that must succeed. +fn output_of(mut cmd: Command, what: &str) -> (String, String) { + let out = cmd.output().expect("spawn command"); + let stdout = String::from_utf8(out.stdout).expect("utf8 stdout"); + let stderr = String::from_utf8(out.stderr).expect("utf8 stderr"); + assert!(out.status.success(), "{what} failed:\n{stdout}{stderr}"); + (stdout, stderr) +} + +fn transpile(src: &str) -> String { + let dir = tempfile::tempdir().expect("tempdir"); + let file = write_source(dir.path(), src); + let mut cmd = ruchy(); + cmd.arg("transpile").arg(file); + output_of(cmd, &format!("ruchy transpile of:\n{src}\n")).0 +} + +/// `ruchy compile` the source, run the binary, return (stdout, stderr). +fn compile_and_run(src: &str) -> (String, String) { + let dir = tempfile::tempdir().expect("tempdir"); + let file = write_source(dir.path(), src); + let bin = dir.path().join("prog_bin"); + let mut cmd = ruchy(); + cmd.arg("compile") + .arg(&file) + .arg("-o") + .arg(&bin) + .timeout(Duration::from_secs(90)); + output_of(cmd, &format!("ruchy compile of:\n{src}\n")); + let mut run = Command::new(&bin); + run.timeout(Duration::from_secs(30)); + output_of(run, "compiled binary") +} + +fn interpret(src: &str) -> String { + let dir = tempfile::tempdir().expect("tempdir"); + let file = write_source(dir.path(), src); + let mut cmd = ruchy(); + cmd.arg("run").arg(file); + output_of(cmd, &format!("ruchy run of:\n{src}\n")).0 +} + +fn main_with(body: &str) -> String { + format!("fun main() {{\n{body}\n}}\n") +} + +/// Compiled stdout is `expected`; with `interp`, `ruchy run` agrees. +fn check(src: &str, expected: &str, interp: bool) { + let (stdout, _) = compile_and_run(src); + assert_eq!( + stdout, + expected, + "compiled stdout of:\n{src}\n{}", + transpile(src) + ); + if interp { + assert_eq!(interpret(src), expected, "interpreter stdout of:\n{src}"); + } +} + +// -------------------------------------------------------------- NESTARRVEC-1 + +#[test] +fn test_nestarrvec_1_push_through_index() { + let src = main_with( + r#" let mut m = [[1], [2]] + m[0].push(9) + println("{:?}", m)"#, + ); + check(&src, "[[1, 9], [2]]\n", true); +} + +#[test] +fn test_nestarrvec_1_insert_through_index() { + let src = main_with( + r#" let mut m = [[1, 2], [3]] + m[1].insert(0, 7) + println("{:?}", m)"#, + ); + check(&src, "[[1, 2], [7, 3]]\n", true); +} + +#[test] +fn test_nestarrvec_1_push_in_loop() { + let src = main_with( + r#" let mut m = [[0], [0], [0]] + for i in 0..3 { + m[i].push(i) + } + println("{:?}", m)"#, + ); + check(&src, "[[0, 0], [0, 1], [0, 2]]\n", true); +} + +#[test] +fn test_nestarrvec_1_outer_and_inner_grown() { + let src = main_with( + r#" let mut m = [[1]] + m[0].push(5) + m.push(m[0].clone()) + println("{:?}", m)"#, + ); + // `ruchy run` has no `clone` array method ("Unknown array method: + // clone"): an interpreter defect outside this transpiler fix. + check(&src, "[[1, 5], [1, 5]]\n", false); +} + +#[test] +fn test_nestarrvec_1_ungrown_nested_stays_array() { + let src = main_with( + r#" let m = [[1], [2]] + println("{:?}", m)"#, + ); + let rust = transpile(&src); + assert!( + !rust.contains("vec !"), + "no vec! for an ungrown literal:\n{rust}" + ); + check(&src, "[[1], [2]]\n", true); +} + +// ------------------------------------------------------------- CLOSUREUNIT-1 + +#[test] +fn test_closureunit_1_unit_closure_last_in_main() { + let src = main_with( + r#" let f = |s: String| println(s) + f("hi")"#, + ); + check(&src, "hi\n", true); +} + +#[test] +fn test_closureunit_1_untyped_int_closure_last_in_main() { + let src = main_with( + r#" let f = |n| println(n) + f(7)"#, + ); + check(&src, "7\n", true); +} + +#[test] +fn test_closureunit_1_shadowed_by_value_closure_still_printed() { + let src = main_with( + r#" let f = |n| println(n) + f(1) + let f = |n| n * 3 + f(2)"#, + ); + check(&src, "1\n6\n", false); +} + +#[test] +fn test_closureunit_1_unit_closure_last_top_level() { + let src = "let f = |s: String| println(s)\nf(\"hi\")\n"; + check(src, "hi\n", true); +} + +#[test] +fn test_closureunit_1_block_body_closure_last() { + let src = main_with( + r#" let g = |n| { + let d = n * 2 + println(d) + } + g(4)"#, + ); + check(&src, "8\n", true); +} + +#[test] +fn test_closureunit_1_value_closure_still_printed() { + let src = main_with( + r#" let h = |n| n + 1 + h(41)"#, + ); + check(&src, "42\n", false); +} diff --git a/tests/g2b_transfix_6.rs b/tests/g2b_transfix_6.rs new file mode 100644 index 000000000..008d53368 --- /dev/null +++ b/tests/g2b_transfix_6.rs @@ -0,0 +1,319 @@ +//! RHLGA-1 transpiler fixes (sixth batch), each checked end to end: +//! `ruchy transpile`, `ruchy compile` (rustc), run the binary, compare exact +//! stdout, and require `ruchy run` to print the same. +//! +//! - PRINTPARAM-1: an untyped function or closure parameter whose emitted +//! (or call-site inferred) type is `String` prints with `{}` through +//! `println(s)`. Before, `p("hi")` and `f("hi")` printed `"hi"` with quotes. +//! - NESTPUSHLIT-1: pushing or inserting an array literal into a binding +//! whose inner literals became `vec![..]` (NESTARRVEC-1) emits the argument +//! as `vec![..]` too. Before, rustc refused it (E0308). + +use assert_cmd::Command; +use std::path::Path; +use std::time::Duration; + +fn ruchy() -> Command { + let mut cmd = Command::cargo_bin("ruchy").expect("ruchy binary is built for tests"); + cmd.timeout(Duration::from_secs(10)); + cmd +} + +fn write_source(dir: &Path, src: &str) -> std::path::PathBuf { + let file = dir.join("prog.ruchy"); + std::fs::write(&file, src).expect("write ruchy source"); + file +} + +/// (stdout, stderr) of a command that must succeed. +fn output_of(mut cmd: Command, what: &str) -> (String, String) { + let out = cmd.output().expect("spawn command"); + let stdout = String::from_utf8(out.stdout).expect("utf8 stdout"); + let stderr = String::from_utf8(out.stderr).expect("utf8 stderr"); + assert!(out.status.success(), "{what} failed:\n{stdout}{stderr}"); + (stdout, stderr) +} + +fn transpile(src: &str) -> String { + let dir = tempfile::tempdir().expect("tempdir"); + let file = write_source(dir.path(), src); + let mut cmd = ruchy(); + cmd.arg("transpile").arg(file); + output_of(cmd, &format!("ruchy transpile of:\n{src}\n")).0 +} + +/// `ruchy compile` the source, run the binary, return (stdout, stderr). +fn compile_and_run(src: &str) -> (String, String) { + let dir = tempfile::tempdir().expect("tempdir"); + let file = write_source(dir.path(), src); + let bin = dir.path().join("prog_bin"); + let mut cmd = ruchy(); + cmd.arg("compile") + .arg(&file) + .arg("-o") + .arg(&bin) + .timeout(Duration::from_secs(90)); + output_of(cmd, &format!("ruchy compile of:\n{src}\n")); + let mut run = Command::new(&bin); + run.timeout(Duration::from_secs(30)); + output_of(run, "compiled binary") +} + +fn interpret(src: &str) -> String { + let dir = tempfile::tempdir().expect("tempdir"); + let file = write_source(dir.path(), src); + let mut cmd = ruchy(); + cmd.arg("run").arg(file); + output_of(cmd, &format!("ruchy run of:\n{src}\n")).0 +} + +fn main_with(body: &str) -> String { + format!("fun main() {{\n{body}\n}}\n") +} + +/// Compiled stdout is `expected`; with `interp`, `ruchy run` agrees. +fn check(src: &str, expected: &str, interp: bool) { + let (stdout, _) = compile_and_run(src); + assert_eq!( + stdout, + expected, + "compiled stdout of:\n{src}\n{}", + transpile(src) + ); + if interp { + assert_eq!(interpret(src), expected, "interpreter stdout of:\n{src}"); + } +} + +// ------------------------------------------------------------- PRINTPARAM-1 + +#[test] +fn test_printparam_1_named_fn_string_param() { + let src = format!("fun p(s) {{ println(s) }}\n{}", main_with(" p(\"hi\")")); + check(&src, "hi\n", true); +} + +#[test] +fn test_printparam_1_named_fn_mixed_params() { + let src = format!( + "fun p(s, n) {{\n println(s)\n println(n)\n}}\n{}", + main_with(" p(\"a\", 3)") + ); + check(&src, "a\n3\n", true); +} + +#[test] +fn test_printparam_1_named_fn_int_param_unchanged() { + let src = format!("fun p(n) {{ println(n) }}\n{}", main_with(" p(5)")); + check(&src, "5\n", true); +} + +#[test] +fn test_printparam_1_closure_string_param() { + let src = main_with( + r#" let f = |s| println(s) + f("hi")"#, + ); + check(&src, "hi\n", true); +} + +#[test] +fn test_printparam_1_closure_called_twice() { + let src = main_with( + r#" let f = |s| println(s) + f("a") + f("b")"#, + ); + check(&src, "a\nb\n", true); +} + +#[test] +fn test_printparam_1_closure_top_level() { + let src = "let f = |s| println(s)\nf(\"hi\")\n"; + check(src, "hi\n", true); +} + +#[test] +fn test_printparam_1_closure_list_param_keeps_debug() { + let src = main_with( + r#" let f = |v| println(v) + f([1, 2])"#, + ); + let rust = transpile(&src); + assert!( + rust.contains("{:?}"), + "a non-string parameter keeps {{:?}}:\n{rust}" + ); + check(&src, "[1, 2]\n", true); +} + +// ------------------------------------------------------------ NESTPUSHLIT-1 + +#[test] +fn test_nestpushlit_1_push_literal_after_elem_growth() { + let src = main_with( + r#" let mut m = [[1]] + m[0].push(2) + m.push([3]) + println("{:?}", m)"#, + ); + check(&src, "[[1, 2], [3]]\n", true); +} + +#[test] +fn test_nestpushlit_1_insert_literal_after_elem_growth() { + let src = main_with( + r#" let mut m = [[1]] + m[0].push(2) + m.insert(0, [5, 6]) + println("{:?}", m)"#, + ); + check(&src, "[[5, 6], [1, 2]]\n", true); +} + +#[test] +fn test_nestpushlit_1_pushed_literal_grown_later() { + let src = main_with( + r#" let mut m = [[1]] + m.push([3]) + m[1].push(4) + println("{:?}", m)"#, + ); + check(&src, "[[1], [3, 4]]\n", true); +} + +#[test] +fn test_nestpushlit_1_no_elem_growth_keeps_arrays() { + let src = main_with( + r#" let mut m = [[1]] + m.push([3]) + println("{:?}", m)"#, + ); + check(&src, "[[1], [3]]\n", true); +} + +// ------------------------------------------------------------ STRRECV-1 + +// `replace`/`repeat` on a receiver the transpiler cannot prove is a string +// (a for-loop variable, a struct field, `String::from(..)`) print as text. +#[test] +fn test_strrecv_1_for_variable_replace_prints_text() { + let src = main_with( + r#" let text = "ab\ncd" + for line in text.lines() { + println(line.replace("a", "b")) + }"#, + ); + check(&src, "bb\ncd\n", true); +} + +#[test] +fn test_strrecv_1_field_replace_prints_text() { + let src = format!( + "struct P {{ name: String }}\n{}", + main_with( + r#" let p = P { name: String::from("xa") } + println(p.name.replace("x", "y"))"# + ) + ); + check(&src, "ya\n", true); +} + +#[test] +fn test_strrecv_1_call_receiver_repeat_prints_text() { + let src = main_with( + r#" let s = String::from("q") + println(s.repeat(2)) + println(String::from("z").repeat(3))"#, + ); + check(&src, "qq\nzzz\n", true); +} + +// A list receiver still prints its `repeat` with `{:?}` (PRINTSTRSCOPE-1). +#[test] +fn test_strrecv_1_list_repeat_stays_debug() { + let src = main_with( + r#" let v = [1] + println(v.repeat(2)) + println([2].repeat(2))"#, + ); + check(&src, "[1, 1]\n[2, 2]\n", true); +} + +// A string rebinding of a list name prints its `repeat` as text again. +#[test] +fn test_strrecv_1_list_name_rebound_to_string() { + let src = main_with( + r#" let v = [1] + println(v.len()) + let v = String::from("ab") + println(v.repeat(2))"#, + ); + check(&src, "1\nabab\n", true); +} + +// A loop variable over string items prints as text. +#[test] +fn test_strrecv_1_loop_variable_over_string_items() { + let src = main_with( + r#" let text = "a b\nc" + for line in text.lines() { + println(line) + } + for w in text.split_whitespace() { + println(w) + } + for p in "x,y".split(",") { + println(p) + } + for s in ["m", "n"] { + println(s) + }"#, + ); + check(&src, "a b\nc\na\nb\nc\nx\ny\nm\nn\n", true); +} + +// A for loop, a while loop or a compound assignment as main's last +// statement is unit: the compiled binary prints nothing for it. +#[test] +fn test_strrecv_1_trailing_loops_are_unit() { + let src = main_with( + r#" let mut i = 0 + while i < 2 { + i += 1 + } + println(i) + for k in [1] { + println(k) + }"#, + ); + check(&src, "2\n1\n", true); + check(&main_with(" let mut j = 1\n j += 1"), "", true); +} + +// A list-typed parameter or annotated binding keeps `repeat` on `{:?}` +// (a `{}` would not build: `Vec` has no Display). +#[test] +fn test_strrecv_1_list_typed_param_repeat_stays_debug() { + let src = format!( + "fun f(v: Vec) {{\n println(v.repeat(2))\n}}\n{}", + main_with(" let w: Vec = vec![3]\n println(w.repeat(2))\n f(vec![1])") + ); + check(&src, "[3, 3]\n[1, 1]\n", true); +} + +// `Option::replace` takes one argument and is not a string. +#[test] +fn test_strrecv_1_option_replace_stays_debug() { + let src = main_with(" let mut o = Some(1)\n println(o.replace(2))\n println(o)"); + check(&src, "Some(1)\nSome(2)\n", false); +} + +// `vec![..]` and `vec![x; n]` receivers and bindings are lists too. +#[test] +fn test_strrecv_1_vec_macro_repeat_stays_debug() { + let src = main_with( + " let v = vec![1]\n println(v.repeat(2))\n let z = vec![0; 2]\n println(z.repeat(2))\n println(vec![5].repeat(2))", + ); + check(&src, "[1, 1]\n[0, 0, 0, 0]\n[5, 5]\n", true); +} diff --git a/tests/g2b_walkall.rs b/tests/g2b_walkall.rs new file mode 100644 index 000000000..ddc8aa834 --- /dev/null +++ b/tests/g2b_walkall.rs @@ -0,0 +1,166 @@ +//! G2BFA2 / RAWIDENT-1 (RHLGA-1), review round 3 of G2 part 2. +//! +//! 1. `&mut self` inference walks every expression kind that holds +//! sub-expressions. The parser builds every generic macro call +//! (`println!`, `format!`, `vec!`, `assert!`) as `MacroInvocation`; a +//! mutation of `self` inside one must still make the receiver `&mut self`, +//! or rustc rejects the method (E0596). +//! 2. A `::` path used as a value (not called), `let f = m::do`, emits a +//! reserved segment as a raw identifier. +//! +//! Every program is transpiled, compiled with rustc and run. + +use assert_cmd::Command; +use std::path::Path; +use std::time::Duration; + +fn ruchy() -> Command { + let mut cmd = Command::cargo_bin("ruchy").expect("ruchy binary is built for tests"); + cmd.timeout(Duration::from_secs(30)); + cmd +} + +fn stdout_of(mut cmd: Command, what: &str) -> String { + let out = cmd.output().expect("spawn command"); + assert!( + out.status.success(), + "{what} failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8(out.stdout).expect("utf8 stdout") +} + +fn write_source(dir: &Path, src: &str) -> std::path::PathBuf { + let file = dir.join("prog.ruchy"); + std::fs::write(&file, src).expect("write ruchy source"); + file +} + +fn transpile(file: &Path) -> String { + let mut cmd = ruchy(); + cmd.arg("transpile").arg(file); + stdout_of(cmd, "ruchy transpile") +} + +/// Compile `rust` with rustc inside `dir` and return the binary's stdout. +fn compiled_stdout(dir: &Path, rust: &str) -> String { + let main_rs = dir.join("main.rs"); + let bin = dir.join("g2b_walkall_bin"); + std::fs::write(&main_rs, rust).expect("write main.rs"); + let mut rustc = Command::new("rustc"); + rustc + .args(["--edition", "2021", "-A", "warnings", "-o"]) + .arg(&bin) + .arg(&main_rs) + .timeout(Duration::from_secs(120)); + stdout_of(rustc, &format!("rustc on transpiled Rust:\n{rust}\n")); + let mut run_bin = Command::new(&bin); + run_bin.timeout(Duration::from_secs(30)); + stdout_of(run_bin, "transpiled binary") +} + +/// Whitespace-free token text, so `fn pop_print (& mut self)` matches. +fn squash(rust: &str) -> String { + rust.chars().filter(|c| !c.is_whitespace()).collect() +} + +// --------------------------------------- 1. mutation inside a macro call + +const POP_PRINT: &str = r#"struct Stack { + stack: Vec +} +impl Stack { + fun pop_print(self) { + println!("{}", self.stack.pop().unwrap()) + } +} +fun main() { + let mut s = Stack { stack: vec![1, 2, 3] } + s.pop_print() + s.pop_print() + println("done") +} +"#; + +#[test] +fn test_g2bfa2_mutation_inside_macro_invocation_makes_receiver_mut() { + let dir = tempfile::tempdir().expect("temp dir"); + let rust = transpile(&write_source(dir.path(), POP_PRINT)); + assert!( + squash(&rust).contains("fnpop_print(&mutself)"), + "`self.stack.pop()` inside println! must give `&mut self`:\n{rust}" + ); +} + +#[test] +fn test_g2bfa2_mutation_inside_macro_invocation_compiles_and_runs() { + let dir = tempfile::tempdir().expect("temp dir"); + let rust = transpile(&write_source(dir.path(), POP_PRINT)); + assert_eq!(compiled_stdout(dir.path(), &rust), "3\n2\ndone\n"); +} + +/// The same shape with a counter, so the interpreter can be the oracle too: +/// `ruchy run` does not persist `self.stack.pop()` on a field (it evaluates to +/// the popped array), a runtime defect outside this transpiler fix. +const BUMP_PRINT: &str = r#"struct Counter { + n: i32 +} +impl Counter { + fun bump_print(self) { + println!("{}", { + self.n += 1 + self.n + }) + } +} +fun main() { + let mut c = Counter { n: 0 } + c.bump_print() + c.bump_print() + println("done") +} +"#; + +#[test] +fn test_g2bfa2_mutation_inside_macro_invocation_matches_run() { + let dir = tempfile::tempdir().expect("temp dir"); + let file = write_source(dir.path(), BUMP_PRINT); + let rust = transpile(&file); + assert!( + squash(&rust).contains("fnbump_print(&mutself)"), + "`self.n += 1` inside println! must give `&mut self`:\n{rust}" + ); + let binary_stdout = compiled_stdout(dir.path(), &rust); + + let mut interpreter = ruchy(); + interpreter.arg("run").arg(&file); + let interpreter_stdout = stdout_of(interpreter, "ruchy run"); + + assert_eq!(binary_stdout, "1\n2\ndone\n"); + assert_eq!(binary_stdout, interpreter_stdout); +} + +// ------------------------------------ 2. reserved `::` segment as a value + +const PATH_VALUE: &str = r"mod m { + pub fun do() -> i32 { + 7 + } +} + +fun main() { + let f = m::do + println(f()) +} +"; + +#[test] +fn test_rawident_1_path_used_as_value_is_raw_and_compiles() { + let dir = tempfile::tempdir().expect("temp dir"); + let rust = transpile(&write_source(dir.path(), PATH_VALUE)); + assert!( + squash(&rust).contains("m::r#do;"), + "`m::do` as a value must be `m::r#do`:\n{rust}" + ); + assert_eq!(compiled_stdout(dir.path(), &rust), "7\n"); +} diff --git a/tests/http_server_cli.rs b/tests/http_server_cli.rs index 8f5e8bed3..05bca20fa 100644 --- a/tests/http_server_cli.rs +++ b/tests/http_server_cli.rs @@ -26,6 +26,22 @@ fn find_available_port() -> u16 { .port() } +/// Helper: Block until the server accepts TCP connections on `port`. +/// +/// Polls for readiness instead of sleeping a fixed interval, so a slow debug +/// build under parallel load does not race the first request. +#[cfg(feature = "notebook")] +fn wait_for_port(port: u16) { + let deadline = std::time::Instant::now() + Duration::from_secs(15); + while std::time::Instant::now() < deadline { + if std::net::TcpStream::connect(("127.0.0.1", port)).is_ok() { + return; + } + std::thread::sleep(Duration::from_millis(50)); + } + panic!("ruchy serve did not accept connections on port {port} within 15s"); +} + /// Helper: Create test directory with files fn create_test_dir() -> TempDir { let dir = TempDir::new().expect("Failed to create temp dir"); @@ -73,6 +89,7 @@ fn test_red_ruchy_serve_requires_directory() { } #[test] +#[cfg(feature = "notebook")] // `ruchy serve` is compiled only with the notebook feature fn test_red_ruchy_serve_starts_server() { // RED: This MUST fail - ruchy serve doesn't exist yet use std::process::{Command, Stdio}; @@ -94,8 +111,7 @@ fn test_red_ruchy_serve_starts_server() { .spawn() .expect("Failed to spawn server"); - // Give server time to start - std::thread::sleep(Duration::from_millis(500)); + wait_for_port(port); // Test HTTP request let response = reqwest::blocking::get(format!("http://127.0.0.1:{port}/index.html")); @@ -109,6 +125,25 @@ fn test_red_ruchy_serve_starts_server() { child.kill().expect("Failed to kill server"); } +#[test] +#[cfg(not(feature = "notebook"))] +fn test_serve_without_notebook_feature_names_the_rebuild_flag() { + // The server tests in this file need a binary built with `--features notebook`; + // default builds must say so instead of failing opaquely. + println!( + "ENVIRONMENT: ruchy built without the notebook feature; server tests are compiled out" + ); + let test_dir = create_test_dir(); + ruchy_cmd() + .arg("serve") + .arg(test_dir.path()) + .arg("--port") + .arg(find_available_port().to_string()) + .assert() + .failure() + .stderr(predicate::str::contains("--features notebook")); +} + #[test] #[ignore = "RED phase: ruchy serve command not yet implemented"] fn test_red_ruchy_serve_default_port_8080() { @@ -141,6 +176,7 @@ fn test_red_ruchy_serve_default_port_8080() { } #[test] +#[cfg(feature = "notebook")] // `ruchy serve` is compiled only with the notebook feature fn test_red_ruchy_serve_shows_startup_message() { // RED: This MUST fail - ruchy serve doesn't exist yet let test_dir = create_test_dir(); @@ -220,6 +256,7 @@ mod property_tests { // ============================================================================ #[test] +#[cfg(feature = "notebook")] // `ruchy serve` is compiled only with the notebook feature fn test_http002_mime_html() { let test_dir = TempDir::new().unwrap(); std::fs::write( @@ -240,7 +277,7 @@ fn test_http002_mime_html() { .spawn() .expect("Failed to spawn server"); - std::thread::sleep(Duration::from_millis(500)); + wait_for_port(port); let response = reqwest::blocking::get(format!("http://127.0.0.1:{port}/index.html")).unwrap(); assert_eq!(response.status(), 200); @@ -250,6 +287,7 @@ fn test_http002_mime_html() { } #[test] +#[cfg(feature = "notebook")] // `ruchy serve` is compiled only with the notebook feature fn test_http002_mime_css() { let test_dir = TempDir::new().unwrap(); std::fs::write(test_dir.path().join("style.css"), "body { margin: 0; }").unwrap(); @@ -266,7 +304,7 @@ fn test_http002_mime_css() { .spawn() .expect("Failed to spawn server"); - std::thread::sleep(Duration::from_millis(500)); + wait_for_port(port); let response = reqwest::blocking::get(format!("http://127.0.0.1:{port}/style.css")).unwrap(); assert_eq!(response.status(), 200); @@ -276,6 +314,7 @@ fn test_http002_mime_css() { } #[test] +#[cfg(feature = "notebook")] // `ruchy serve` is compiled only with the notebook feature fn test_http002_mime_javascript() { let test_dir = TempDir::new().unwrap(); std::fs::write(test_dir.path().join("app.js"), "console.log('test');").unwrap(); @@ -292,7 +331,7 @@ fn test_http002_mime_javascript() { .spawn() .expect("Failed to spawn server"); - std::thread::sleep(Duration::from_millis(500)); + wait_for_port(port); let response = reqwest::blocking::get(format!("http://127.0.0.1:{port}/app.js")).unwrap(); assert_eq!(response.status(), 200); @@ -311,6 +350,7 @@ fn test_http002_mime_javascript() { } #[test] +#[cfg(feature = "notebook")] // `ruchy serve` is compiled only with the notebook feature fn test_http002_mime_wasm() { // CRITICAL: WASM files MUST have application/wasm MIME type let test_dir = TempDir::new().unwrap(); @@ -333,7 +373,7 @@ fn test_http002_mime_wasm() { .spawn() .expect("Failed to spawn server"); - std::thread::sleep(Duration::from_millis(500)); + wait_for_port(port); let response = reqwest::blocking::get(format!("http://127.0.0.1:{port}/module.wasm")).unwrap(); assert_eq!(response.status(), 200); @@ -347,6 +387,7 @@ fn test_http002_mime_wasm() { } #[test] +#[cfg(feature = "notebook")] // `ruchy serve` is compiled only with the notebook feature fn test_http002_mime_json() { let test_dir = TempDir::new().unwrap(); std::fs::write(test_dir.path().join("data.json"), r#"{"test": true}"#).unwrap(); @@ -363,7 +404,7 @@ fn test_http002_mime_json() { .spawn() .expect("Failed to spawn server"); - std::thread::sleep(Duration::from_millis(500)); + wait_for_port(port); let response = reqwest::blocking::get(format!("http://127.0.0.1:{port}/data.json")).unwrap(); assert_eq!(response.status(), 200); @@ -380,6 +421,7 @@ fn test_http002_mime_json() { // ============================================================================ #[test] +#[cfg(feature = "notebook")] // `ruchy serve` is compiled only with the notebook feature fn test_http003_wasm_coop_header() { // CRITICAL: WASM files MUST have Cross-Origin-Opener-Policy for SharedArrayBuffer let test_dir = TempDir::new().unwrap(); @@ -401,7 +443,7 @@ fn test_http003_wasm_coop_header() { .spawn() .expect("Failed to spawn server"); - std::thread::sleep(Duration::from_millis(500)); + wait_for_port(port); let response = reqwest::blocking::get(format!("http://127.0.0.1:{port}/app.wasm")).unwrap(); assert_eq!(response.status(), 200); @@ -418,6 +460,7 @@ fn test_http003_wasm_coop_header() { } #[test] +#[cfg(feature = "notebook")] // `ruchy serve` is compiled only with the notebook feature fn test_http003_wasm_coep_header() { // CRITICAL: WASM files MUST have Cross-Origin-Embedder-Policy for SharedArrayBuffer let test_dir = TempDir::new().unwrap(); @@ -439,7 +482,7 @@ fn test_http003_wasm_coep_header() { .spawn() .expect("Failed to spawn server"); - std::thread::sleep(Duration::from_millis(500)); + wait_for_port(port); let response = reqwest::blocking::get(format!("http://127.0.0.1:{port}/app.wasm")).unwrap(); assert_eq!(response.status(), 200); @@ -456,6 +499,7 @@ fn test_http003_wasm_coep_header() { } #[test] +#[cfg(feature = "notebook")] // `ruchy serve` is compiled only with the notebook feature fn test_http003_html_coop_header() { // HTML files serving WASM also need COOP/COEP headers let test_dir = TempDir::new().unwrap(); @@ -477,7 +521,7 @@ fn test_http003_html_coop_header() { .spawn() .expect("Failed to spawn server"); - std::thread::sleep(Duration::from_millis(500)); + wait_for_port(port); let response = reqwest::blocking::get(format!("http://127.0.0.1:{port}/index.html")).unwrap(); assert_eq!(response.status(), 200); diff --git a/tests/http_watch_mode.rs b/tests/http_watch_mode.rs index 1bfef7e93..9c93b9c4b 100644 --- a/tests/http_watch_mode.rs +++ b/tests/http_watch_mode.rs @@ -10,7 +10,6 @@ use assert_cmd::Command; use std::fs; -use std::thread; use std::time::Duration; use tempfile::TempDir; @@ -36,6 +35,7 @@ fn test_serve_help_shows_watch_flags() { } #[test] +#[cfg(feature = "notebook")] // `ruchy serve` is compiled only with the notebook feature fn test_serve_with_watch_starts_successfully() { let temp_dir = TempDir::new().unwrap(); let test_file = temp_dir.path().join("index.html"); @@ -57,7 +57,7 @@ fn test_serve_with_watch_starts_successfully() { .expect("Failed to start server"); // Give server time to start - thread::sleep(Duration::from_secs(2)); + std::thread::sleep(Duration::from_secs(2)); // Server should be running assert!( @@ -117,7 +117,7 @@ fn test_pid_file_replaces_stale_entry() { } #[test] -#[cfg(unix)] +#[cfg(all(unix, feature = "notebook"))] // `ruchy serve` needs the notebook feature fn test_graceful_shutdown_on_sigterm() { use nix::sys::signal::{kill, Signal}; use nix::unistd::Pid; @@ -143,7 +143,7 @@ fn test_graceful_shutdown_on_sigterm() { .expect("Failed to start server"); // Give server time to start and create PID file - thread::sleep(Duration::from_secs(2)); + std::thread::sleep(Duration::from_secs(2)); // PID file should exist assert!(pid_path.exists(), "PID file should be created"); @@ -159,7 +159,7 @@ fn test_graceful_shutdown_on_sigterm() { if child.try_wait().unwrap().is_some() { break; } - thread::sleep(Duration::from_millis(100)); + std::thread::sleep(Duration::from_millis(100)); } // Server should have exited gracefully @@ -170,7 +170,7 @@ fn test_graceful_shutdown_on_sigterm() { ); // PID file should be cleaned up - thread::sleep(Duration::from_millis(100)); // Give time for cleanup + std::thread::sleep(Duration::from_millis(100)); // Give time for cleanup assert!( !pid_path.exists(), "PID file should be removed on graceful shutdown" diff --git a/tests/idxassign_1_place_targets.rs b/tests/idxassign_1_place_targets.rs new file mode 100644 index 000000000..1a6b78df1 --- /dev/null +++ b/tests/idxassign_1_place_targets.rs @@ -0,0 +1,168 @@ +//! IDXASSIGN-1: an expression in place (lvalue) position is emitted as a +//! place, with no `.clone()` anywhere in its chain. Place positions are the +//! target of `=` and of a compound assignment (`+=` etc.), the receiver chain +//! of a mutating method call (`o.rows[0].vals.push(x)`), and the operand of +//! `&mut`. The transpiler used to emit `o.items[0 as usize].clone().z = 5`, +//! which assigns to a temporary: the compiled binary silently printed the old +//! value. +//! +//! Every program is transpiled, compiled with rustc and run; the binary must +//! print the expected output. Where the interpreter runs the program, it must +//! print the same output. + +use assert_cmd::Command; +use std::path::Path; +use std::time::Duration; + +fn ruchy() -> Command { + let mut cmd = Command::cargo_bin("ruchy").expect("ruchy binary is built for tests"); + cmd.timeout(Duration::from_secs(30)); + cmd +} + +fn write_source(dir: &Path, src: &str) -> std::path::PathBuf { + let file = dir.join("prog.ruchy"); + std::fs::write(&file, src).expect("write ruchy source"); + file +} + +fn stdout_of(mut cmd: Command, what: &str) -> String { + let out = cmd.output().expect("spawn command"); + assert!( + out.status.success(), + "{what} failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8(out.stdout).expect("utf8 stdout") +} + +fn transpile(dir: &Path, src: &str) -> String { + let file = write_source(dir, src); + let mut cmd = ruchy(); + cmd.arg("transpile").arg(file); + stdout_of(cmd, "ruchy transpile") +} + +fn interpret(dir: &Path, src: &str) -> String { + let file = write_source(dir, src); + let mut cmd = ruchy(); + cmd.arg("run").arg(file); + stdout_of(cmd, "ruchy run") +} + +fn rustc_run(dir: &Path, rust: &str) -> String { + let main_rs = dir.join("main.rs"); + let bin = dir.join("prog_bin"); + std::fs::write(&main_rs, rust).expect("write main.rs"); + let mut rustc = Command::new("rustc"); + rustc + .args(["--edition", "2021", "-A", "warnings", "-o"]) + .arg(&bin) + .arg(&main_rs) + .timeout(Duration::from_secs(120)); + stdout_of(rustc, &format!("rustc on transpiled Rust:\n{rust}\n")); + let mut run = Command::new(&bin); + run.timeout(Duration::from_secs(30)); + stdout_of(run, "transpiled binary") +} + +/// Transpile + rustc + run must print `expected`; returns the Rust source. +fn assert_compiled(src: &str, expected: &str) -> String { + let dir = tempfile::tempdir().expect("tempdir"); + let rust = transpile(dir.path(), src); + let actual = rustc_run(dir.path(), &rust); + assert_eq!(actual, expected, "compiled output\nRust:\n{rust}"); + rust +} + +/// Interpreter and compiled binary must both print `expected`. +fn assert_both(src: &str, expected: &str) -> String { + let dir = tempfile::tempdir().expect("tempdir"); + assert_eq!( + interpret(dir.path(), src), + expected, + "interpreter output\nsource:\n{src}" + ); + assert_compiled(src, expected) +} + +const ITEMS: &str = "struct P { z: i32 }\nstruct O { items: Vec

}\n"; + +#[test] +fn test_idxassign_1_01_field_of_indexed_element_assign() { + let src = format!( + "{ITEMS}fun main() {{\n let mut o = O {{ items: vec![P {{ z: 1 }}, P {{ z: 2 }}] }}\n \ + o.items[0].z = 5\n println!(\"{{}} {{}}\", o.items[0].z, o.items[1].z)\n}}\n" + ); + let rust = assert_both(&src, "5 2\n"); + assert!( + !rust.contains(".clone().z ="), + "assignment target must be a place:\n{rust}" + ); +} + +#[test] +fn test_idxassign_1_02_field_of_indexed_element_compound_assign() { + let src = format!( + "{ITEMS}fun main() {{\n let mut o = O {{ items: vec![P {{ z: 1 }}] }}\n \ + o.items[0].z += 3\n o.items[0].z *= 2\n println!(\"{{}}\", o.items[0].z)\n}}\n" + ); + assert_both(&src, "8\n"); +} + +/// Compiled only: the interpreter does not write `push` through an indexed +/// field chain back to `t` (a runtime defect reported with this ticket). +#[test] +fn test_idxassign_1_03_mutating_method_on_indexed_chain() { + let src = "struct Row { vals: Vec }\nstruct T { rows: Vec }\nfun main() {\n \ + let mut t = T { rows: vec![Row { vals: vec![1] }] }\n \ + t.rows[0].vals.push(9)\n t.rows[0].vals.push(4)\n \ + println!(\"{} {}\", t.rows[0].vals.len(), t.rows[0].vals[1])\n}\n"; + assert_compiled(src, "3 9\n"); +} + +#[test] +fn test_idxassign_1_04_mut_ref_argument_of_indexed_element() { + let src = format!( + "{ITEMS}fun bump(p: &mut P) {{\n p.z = p.z + 10\n}}\nfun main() {{\n \ + let mut o = O {{ items: vec![P {{ z: 1 }}] }}\n bump(&mut o.items[0])\n \ + println!(\"{{}}\", o.items[0].z)\n}}\n" + ); + assert_compiled(&src, "11\n"); +} + +#[test] +fn test_idxassign_1_05_nested_index_field_chain() { + let src = "struct D { d: i32 }\nstruct C { c: Vec }\nstruct B { b: Vec }\n\ + fun main() {\n let mut a = B { b: vec![C { c: vec![D { d: 0 }, D { d: 0 }] }] }\n \ + let i = 0\n let j = 1\n a.b[i].c[j].d = 7\n a.b[i].c[j].d += 1\n \ + println!(\"{} {}\", a.b[0].c[0].d, a.b[0].c[1].d)\n}\n"; + assert_both(src, "0 8\n"); +} + +#[test] +fn test_idxassign_1_06_reads_still_compile() { + let src = "struct N { name: String, z: i32 }\nstruct O { items: Vec }\nfun main() {\n \ + let o = O { items: vec![N { name: \"a\".to_string(), z: 3 }] }\n \ + let y = o.items[0].z\n let s = o.items[0].name\n \ + println!(\"{} {} {}\", y, s, o.items[0].name)\n}\n"; + assert_both(src, "3 a a\n"); +} + +/// Compiled only: the interpreter rejects `v[1] += 5` with "Invalid compound +/// assignment target" (a runtime defect reported with this ticket). +#[test] +fn test_idxassign_1_07_plain_index_assign_unchanged() { + let src = "fun main() {\n let mut v = vec![1, 2, 3]\n v[0] = 9\n v[1] += 5\n \ + println!(\"{} {} {}\", v[0], v[1], v[2])\n}\n"; + assert_compiled(src, "9 7 3\n"); +} + +#[test] +fn test_idxassign_1_08_whole_element_replace_in_field_vec() { + let src = format!( + "{ITEMS}fun main() {{\n let mut o = O {{ items: vec![P {{ z: 1 }}, P {{ z: 2 }}] }}\n \ + o.items[1] = P {{ z: 7 }}\n println!(\"{{}} {{}}\", o.items[0].z, o.items[1].z)\n}}\n" + ); + assert_both(&src, "1 7\n"); +} diff --git a/tests/letvec_1_growable_locals.rs b/tests/letvec_1_growable_locals.rs new file mode 100644 index 000000000..4548e2ce3 --- /dev/null +++ b/tests/letvec_1_growable_locals.rs @@ -0,0 +1,299 @@ +//! LETVEC-1: an array literal bound by `let` is growable in the interpreter, +//! so the transpiled Rust must be a `Vec` whenever the program needs one. +//! +//! Before the fix `let mut v = [1, 2]; v.push(3)` transpiled to +//! `let mut v = [1, 2]; v.push(3);` and rustc failed with E0599 (no `push` +//! on `[{integer}; 2]`), with or without a `Vec` annotation, in a +//! `fun main` and in a top-level script. +//! +//! The literal becomes `vec![..]` when the binding is annotated `Vec<..>` or +//! is, later in its scope, the receiver of a Vec-only method. A literal that +//! is never grown stays a fixed-size array (`sort`/`reverse` alone work on +//! Rust arrays and do not force a `Vec`). + +use assert_cmd::Command; +use std::path::Path; +use std::time::Duration; + +fn ruchy() -> Command { + let mut cmd = Command::cargo_bin("ruchy").expect("ruchy binary is built for tests"); + cmd.timeout(Duration::from_secs(10)); + cmd +} + +fn write_source(dir: &Path, src: &str) -> std::path::PathBuf { + let file = dir.join("prog.ruchy"); + std::fs::write(&file, src).expect("write ruchy source"); + file +} + +fn stdout_of(mut cmd: Command, what: &str) -> String { + let out = cmd.output().expect("spawn command"); + assert!( + out.status.success(), + "{what} failed: {}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8(out.stdout).expect("utf8 stdout") +} + +fn interpret(dir: &Path, src: &str) -> String { + let file = write_source(dir, src); + let mut cmd = ruchy(); + cmd.arg("run").arg(file); + stdout_of(cmd, "ruchy run") +} + +fn transpile(dir: &Path, src: &str) -> String { + let file = write_source(dir, src); + let mut cmd = ruchy(); + cmd.arg("transpile").arg(file); + stdout_of(cmd, "ruchy transpile") +} + +fn transpile_str(src: &str) -> String { + let dir = tempfile::tempdir().expect("tempdir"); + transpile(dir.path(), src) +} + +/// `ruchy compile` (rustc) the source, then run the binary. +fn compile_and_run(dir: &Path, src: &str) -> String { + let file = write_source(dir, src); + let bin = dir.join("prog_bin"); + let mut cmd = ruchy(); + cmd.arg("compile") + .arg(&file) + .arg("-o") + .arg(&bin) + .timeout(Duration::from_secs(60)); + stdout_of(cmd, &format!("ruchy compile of:\n{src}\n")); + let mut run = Command::new(&bin); + run.timeout(Duration::from_secs(30)); + stdout_of(run, "compiled binary") +} + +/// Strip whitespace so `vec ! [1 , 2]` and `vec![1,2]` compare equal. +fn squash(s: &str) -> String { + s.chars().filter(|c| !c.is_whitespace()).collect() +} + +/// The four binding shapes: (label, program) for `op` applied to +/// `[3, 1, 2, 2]`, printing the array and its length afterwards. +fn forms(op: &str) -> Vec<(&'static str, String)> { + let body = |decl: &str, indent: &str| { + format!("{indent}let mut v{decl} = [3, 1, 2, 2]\n{indent}v.{op}\n{indent}println(v)\n{indent}println(v.len())\n") + }; + vec![ + ( + "fun main", + format!("fun main() {{\n{}}}\n", body("", " ")), + ), + ( + "fun main, Vec", + format!("fun main() {{\n{}}}\n", body(": Vec", " ")), + ), + ("script", body("", "")), + ("script, Vec", body(": Vec", "")), + ] +} + +/// Every form transpiles to a `vec![..]` binding, compiles with rustc, and +/// prints `expected` (checked against the interpreter when it supports `op`). +fn assert_grows(op: &str, expected: &str) { + for (label, src) in forms(op) { + let dir = tempfile::tempdir().expect("tempdir"); + let rust = transpile(dir.path(), &src); + assert!( + squash(&rust).contains("vec![3,1,2,2]"), + "{label}, `{op}`: expected a vec![..] binding in:\n{rust}" + ); + let actual = compile_and_run(dir.path(), &src); + assert_eq!(actual, expected, "{label}, `{op}`: rust:\n{rust}"); + } +} + +/// As [`assert_grows`], and the interpreter prints the same thing. +fn assert_grows_like_interpreter(op: &str, expected: &str) { + for (label, src) in forms(op) { + let dir = tempfile::tempdir().expect("tempdir"); + assert_eq!( + interpret(dir.path(), &src), + expected, + "{label}, `{op}`: interpreter" + ); + } + assert_grows(op, expected); +} + +#[test] +fn test_letvec_1_01_push() { + assert_grows_like_interpreter("push(9)", "[3, 1, 2, 2, 9]\n5\n"); +} + +#[test] +fn test_letvec_1_02_pop() { + assert_grows_like_interpreter("pop()", "[3, 1, 2]\n3\n"); +} + +#[test] +fn test_letvec_1_03_extend() { + assert_grows_like_interpreter("extend([7, 8])", "[3, 1, 2, 2, 7, 8]\n6\n"); +} + +#[test] +fn test_letvec_1_04_insert() { + assert_grows_like_interpreter("insert(0, 9)", "[9, 3, 1, 2, 2]\n5\n"); +} + +#[test] +fn test_letvec_1_05_remove() { + assert_grows_like_interpreter("remove(1)", "[3, 2, 2]\n3\n"); +} + +#[test] +fn test_letvec_1_06_clear() { + assert_grows_like_interpreter("clear()", "[]\n0\n"); +} + +#[test] +fn test_letvec_1_07_truncate() { + assert_grows_like_interpreter("truncate(2)", "[3, 1]\n2\n"); +} + +#[test] +fn test_letvec_1_08_dedup() { + assert_grows_like_interpreter("dedup()", "[3, 1, 2]\n3\n"); +} + +#[test] +fn test_letvec_1_09_append() { + assert_grows_like_interpreter("append([7, 8])", "[3, 1, 2, 2, 7, 8]\n6\n"); +} + +#[test] +fn test_letvec_1_10_retain() { + // retain/drain are not interpreter array methods: rustc output only. + assert_grows("retain(|x| *x > 1)", "[3, 2, 2]\n3\n"); +} + +#[test] +fn test_letvec_1_11_drain() { + assert_grows("drain(0..2)", "[2, 2]\n2\n"); +} + +#[test] +fn test_letvec_1_12_resize() { + assert_grows_like_interpreter("resize(6, 0)", "[3, 1, 2, 2, 0, 0]\n6\n"); +} + +#[test] +fn test_letvec_1_13_extend_from_slice() { + assert_grows_like_interpreter("extend_from_slice(&[7])", "[3, 1, 2, 2, 7]\n5\n"); +} + +#[test] +fn test_letvec_1_14_never_grown_stays_array() { + let src = "fun main() {\n let v = [1, 2, 3]\n println(v.len())\n}\n"; + let rust = squash(&transpile_str(src)); + assert!( + rust.contains("letv=[1,2,3];"), + "expected a fixed array:\n{rust}" + ); + assert!(!rust.contains("vec!"), "unexpected vec!:\n{rust}"); +} + +#[test] +fn test_letvec_1_15_sort_and_reverse_alone_stay_array() { + let src = "fun main() {\n let mut v = [3, 1, 2]\n v.sort()\n v.reverse()\n println(v)\n}\n"; + let dir = tempfile::tempdir().expect("tempdir"); + let rust = transpile(dir.path(), src); + assert!( + squash(&rust).contains("letmutv=[3,1,2];"), + "expected a fixed array:\n{rust}" + ); + assert_eq!(compile_and_run(dir.path(), src), "[3, 2, 1]\n"); +} + +#[test] +fn test_letvec_1_16_only_the_grown_binding_becomes_vec() { + let src = "fun main() {\n let a = [1, 2]\n let mut b = [3]\n b.push(4)\n println(a.len() + b.len())\n}\n"; + let dir = tempfile::tempdir().expect("tempdir"); + let rust = squash(&transpile(dir.path(), src)); + assert!(rust.contains("leta=[1,2];"), "a stays an array:\n{rust}"); + assert!( + rust.contains("letmutb=vec![3];"), + "b becomes a vec:\n{rust}" + ); + assert_eq!(compile_and_run(dir.path(), src), "4\n"); +} + +#[test] +fn test_letvec_1_17_growth_inside_nested_block() { + let src = "fun main() {\n let mut v = [1]\n if true {\n for i in 0..3 {\n v.push(i)\n }\n }\n println(v.len())\n}\n"; + let dir = tempfile::tempdir().expect("tempdir"); + let expected = interpret(dir.path(), src); + assert_eq!(expected, "4\n"); + assert_eq!(compile_and_run(dir.path(), src), expected); +} + +// ---------- RHLGA-1 review F1: the scan follows scope ---------- + +#[test] +fn test_letvec_1_18_shadowing_let_ends_the_scan() { + let src = "fun main() {\n let v = [1, 2]\n println(v.len())\n let mut v = [3]\n v.push(4)\n println(v.len())\n}\n"; + let dir = tempfile::tempdir().expect("tempdir"); + let rust = squash(&transpile(dir.path(), src)); + assert!( + rust.contains("letv=[1,2];"), + "outer v stays an array:\n{rust}" + ); + assert!( + rust.contains("letmutv=vec![3];"), + "the shadowing v becomes a vec:\n{rust}" + ); + assert_eq!(compile_and_run(dir.path(), src), "2\n2\n"); +} + +#[test] +fn test_letvec_1_19_nested_block_rebinding_does_not_count() { + let src = "fun main() {\n let v = [1, 2]\n if true {\n let mut v = [5]\n v.push(6)\n println(v.len())\n }\n println(v.len())\n}\n"; + let dir = tempfile::tempdir().expect("tempdir"); + let rust = squash(&transpile(dir.path(), src)); + assert!( + rust.contains("letv=[1,2];"), + "outer v stays an array:\n{rust}" + ); + assert_eq!(compile_and_run(dir.path(), src), "2\n2\n"); +} + +#[test] +fn test_letvec_1_20_closure_param_rebinding_does_not_count() { + let src = + "fun main() {\n let v = [1, 2]\n let f = |v| v.push(3)\n println(v.len())\n}\n"; + let rust = squash(&transpile_str(src)); + assert!( + rust.contains("letv=[1,2];"), + "outer v stays an array:\n{rust}" + ); +} + +#[test] +fn test_letvec_1_21_shadowing_value_still_sees_the_outer_binding() { + let src = "fun main() {\n let mut v = [1]\n let v = { v.push(2); v }\n println(v.len())\n}\n"; + let rust = squash(&transpile_str(src)); + assert!(rust.contains("letmutv=vec![1];"), "outer v grows:\n{rust}"); +} + +// ---------- RHLGA-1 review F2: append copies the argument in ---------- + +#[test] +fn test_letvec_1_22_append_leaves_argument_usable_through_rustc() { + let src = "fun main() {\n let mut a = [1, 2]\n let mut b = [3]\n b.push(4)\n a.append(b)\n println(a)\n println(b)\n let c = [5, 6]\n a.append(c)\n println(a.len() + c.len())\n}\n"; + let dir = tempfile::tempdir().expect("tempdir"); + let expected = interpret(dir.path(), src); + assert_eq!(expected, "[1, 2, 3, 4]\n[3, 4]\n8\n"); + let rust = squash(&transpile(dir.path(), src)); + assert!(rust.contains("a.extend_from_slice(&b)"), "rust:\n{rust}"); + assert_eq!(compile_and_run(dir.path(), src), expected); +} diff --git a/tests/nestassign_1_nested_field_assign.rs b/tests/nestassign_1_nested_field_assign.rs new file mode 100644 index 000000000..fc9481946 --- /dev/null +++ b/tests/nestassign_1_nested_field_assign.rs @@ -0,0 +1,203 @@ +//! NESTASSIGN-1: assignment to a nested field (`o.inner.z = 5`, +//! `o.inner.z += 1`, `self.a.b = x`, `o.items[0].z = 5`) writes the value +//! through every value-typed parent back to the root variable. It used to fail +//! with "Runtime error: Complex field access not supported" because field +//! assignment only accepted a variable as the object. +//! +//! Each program is also transpiled and compiled with rustc; the compiled +//! binary must print the same output as the interpreter. + +use assert_cmd::Command; +use std::path::Path; +use std::time::Duration; + +fn ruchy() -> Command { + let mut cmd = Command::cargo_bin("ruchy").expect("ruchy binary is built for tests"); + cmd.timeout(Duration::from_secs(30)); + cmd +} + +fn write_source(dir: &Path, src: &str) -> std::path::PathBuf { + let file = dir.join("prog.ruchy"); + std::fs::write(&file, src).expect("write ruchy source"); + file +} + +fn stdout_of(mut cmd: Command, what: &str) -> String { + let out = cmd.output().expect("spawn command"); + assert!( + out.status.success(), + "{what} failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8(out.stdout).expect("utf8 stdout") +} + +fn interpret(dir: &Path, src: &str) -> String { + let file = write_source(dir, src); + let mut cmd = ruchy(); + cmd.arg("run").arg(file); + stdout_of(cmd, "ruchy run") +} + +fn run_file(src: &str) -> String { + let dir = tempfile::tempdir().expect("tempdir"); + interpret(dir.path(), src) +} + +fn transpile(dir: &Path, src: &str) -> String { + let file = write_source(dir, src); + let mut cmd = ruchy(); + cmd.arg("transpile").arg(file); + stdout_of(cmd, "ruchy transpile") +} + +fn rustc_run(dir: &Path, rust: &str) -> String { + let main_rs = dir.join("main.rs"); + let bin = dir.join("prog_bin"); + std::fs::write(&main_rs, rust).expect("write main.rs"); + let mut rustc = Command::new("rustc"); + rustc + .args(["--edition", "2021", "-A", "warnings", "-o"]) + .arg(&bin) + .arg(&main_rs) + .timeout(Duration::from_secs(120)); + stdout_of(rustc, &format!("rustc on transpiled Rust:\n{rust}\n")); + let mut run = Command::new(&bin); + run.timeout(Duration::from_secs(30)); + stdout_of(run, "transpiled binary") +} + +/// Interpret, then transpile + rustc + run; both must print `expected`. +fn assert_both(src: &str, expected: &str) { + assert_eq!( + run_file(src), + expected, + "interpreter output\nsource:\n{src}" + ); + let dir = tempfile::tempdir().expect("tempdir"); + let rust = transpile(dir.path(), src); + let actual = rustc_run(dir.path(), &rust); + assert_eq!( + actual, expected, + "transpiled Rust output\nsource:\n{src}\nrust:\n{rust}" + ); +} + +const NEST: &str = "struct In { z: i32 }\nstruct Out { inner: In }\n"; + +#[test] +fn test_nestassign_1_01_two_level_assign() { + let src = format!( + "{NEST}fun main() {{ + let mut o = Out {{ inner: In {{ z: 1 }} }} + o.inner.z = 5 + println(o.inner.z) +}} +" + ); + assert_both(&src, "5\n"); +} + +#[test] +fn test_nestassign_1_02_two_level_compound_assign() { + let src = format!( + "{NEST}fun main() {{ + let mut o = Out {{ inner: In {{ z: 1 }} }} + o.inner.z += 1 + o.inner.z *= 10 + println(o.inner.z) +}} +" + ); + assert_both(&src, "20\n"); +} + +#[test] +fn test_nestassign_1_03_three_level_assign() { + let src = "struct C { v: i32 } +struct B { c: C } +struct A { b: B } +fun main() { + let mut a = A { b: B { c: C { v: 1 } } } + a.b.c.v = 7 + a.b.c.v += 1 + println(a.b.c.v) +} +"; + assert_both(src, "8\n"); +} + +#[test] +fn test_nestassign_1_04_self_nested_assign_in_method() { + let src = format!( + "{NEST}impl Out {{ + fun set(&mut self, x: i32) {{ + self.inner.z = x + }} + fun bump(&mut self) {{ + self.inner.z += 1 + }} +}} +fun main() {{ + let mut o = Out {{ inner: In {{ z: 1 }} }} + o.set(40) + o.bump() + println(o.inner.z) +}} +" + ); + assert_both(&src, "41\n"); +} + +#[test] +fn test_nestassign_1_05_sibling_fields_untouched() { + let src = "struct In { z: i32, w: i32 } +struct Out { inner: In, k: i32 } +fun main() { + let mut o = Out { inner: In { z: 1, w: 2 }, k: 3 } + o.inner.z = 9 + println(o.inner.z) + println(o.inner.w) + println(o.k) +} +"; + assert_both(src, "9\n2\n3\n"); +} + +/// IDXASSIGN-1: the transpiler used to emit `o.items[0 as usize].clone().z = 5`, +/// which assigned to a temporary; the compiled binary must now agree. +#[test] +fn test_nestassign_1_06_index_in_chain_assign() { + let src = "struct In { z: i32 } +struct Out { items: Vec } +fun main() { + let mut o = Out { items: vec![In { z: 1 }, In { z: 2 }] } + o.items[0].z = 5 + o.items[0].z += 1 + o.items[1] = In { z: 7 } + println(o.items[0].z) + println(o.items[1].z) +} +"; + assert_both(src, "6\n7\n"); +} + +#[test] +fn test_nestassign_1_07_field_on_non_object_is_an_error() { + let dir = tempfile::tempdir().expect("tempdir"); + let file = write_source( + dir.path(), + "fun main() {\n let mut n = 3\n n.z = 5\n println(n)\n}\n", + ); + let out = ruchy().arg("run").arg(file).output().expect("spawn ruchy"); + assert!( + !out.status.success(), + "assigning a field on an integer must fail" + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("non-object"), + "error names the non-object target: {stderr}" + ); +} diff --git a/tests/opt_022_inline_caching.rs b/tests/opt_022_inline_caching.rs index 566afc95b..eebccb28f 100644 --- a/tests/opt_022_inline_caching.rs +++ b/tests/opt_022_inline_caching.rs @@ -78,7 +78,7 @@ fun main() { .arg(code) .assert() .success() - .stdout("10.0\n"); + .stdout("10\n"); } // ============================================================================ diff --git a/tests/optmethods_1_option_methods.rs b/tests/optmethods_1_option_methods.rs new file mode 100644 index 000000000..4e7c68771 --- /dev/null +++ b/tests/optmethods_1_option_methods.rs @@ -0,0 +1,362 @@ +//! OPTMETHODS-1: `Option`/`Result` methods in the interpreter. +//! +//! `Some(3).unwrap()` failed with "Method 'unwrap' not found for type +//! enum_variant" and `{"a": 1}.get("a")` with "Object is missing __type +//! marker" (the Option methods were lost in SPRINT-65). +//! +//! The interpreter keeps nil-for-absent library results (`pop`, `first`, +//! `get` return the element or nil), so these methods accept both forms: +//! `Some(v)`/`Ok(v)` and a plain non-nil value are present; `None`, `Err(e)` +//! and nil are absent. A plain value only reaches these methods for names +//! its own type does not already handle. + +use assert_cmd::Command; +use std::path::Path; +use std::time::Duration; + +fn ruchy() -> Command { + let mut cmd = Command::cargo_bin("ruchy").expect("ruchy binary is built for tests"); + cmd.timeout(Duration::from_secs(30)); + cmd +} + +fn write_source(dir: &Path, src: &str) -> std::path::PathBuf { + let file = dir.join("prog.ruchy"); + std::fs::write(&file, src).expect("write ruchy source"); + file +} + +fn stdout_of(mut cmd: Command, what: &str) -> String { + let out = cmd.output().expect("spawn command"); + assert!( + out.status.success(), + "{what} failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8(out.stdout).expect("utf8 stdout") +} + +fn interpret(dir: &Path, src: &str) -> String { + let file = write_source(dir, src); + let mut cmd = ruchy(); + cmd.arg("run").arg(file); + stdout_of(cmd, "ruchy run") +} + +fn run_file(src: &str) -> String { + let dir = tempfile::tempdir().expect("tempdir"); + interpret(dir.path(), src) +} + +/// Run a program that must fail; return its stdout + stderr. +fn run_failing(src: &str) -> String { + let dir = tempfile::tempdir().expect("tempdir"); + let file = write_source(dir.path(), src); + let mut cmd = ruchy(); + let out = cmd.arg("run").arg(file).output().expect("spawn ruchy"); + let text = format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + assert!(!out.status.success(), "expected failure, got:\n{text}"); + text +} + +fn main_of(body: &str) -> String { + format!("fun main() {{\n{body}\n}}\n") +} + +fn lines(values: &[&str]) -> String { + values.iter().map(|v| format!("{v}\n")).collect() +} + +fn transpile(dir: &Path, src: &str) -> String { + let file = write_source(dir, src); + let mut cmd = ruchy(); + cmd.arg("transpile").arg(file); + stdout_of(cmd, "ruchy transpile") +} + +fn rustc_run(dir: &Path, rust: &str) -> String { + let main_rs = dir.join("main.rs"); + let bin = dir.join("prog_bin"); + std::fs::write(&main_rs, rust).expect("write main.rs"); + let mut rustc = Command::new("rustc"); + rustc + .args(["--edition", "2021", "-A", "warnings", "-o"]) + .arg(&bin) + .arg(&main_rs) + .timeout(Duration::from_secs(120)); + stdout_of(rustc, &format!("rustc on transpiled Rust:\n{rust}\n")); + let mut run = Command::new(&bin); + run.timeout(Duration::from_secs(30)); + stdout_of(run, "transpiled binary") +} + +/// Transpile, compile, run; assert interpreter == rustc output. Returns the output. +fn differential(src: &str) -> String { + let dir = tempfile::tempdir().expect("tempdir"); + let rust = transpile(dir.path(), src); + let expected = interpret(dir.path(), src); + let actual = rustc_run(dir.path(), &rust); + assert_eq!( + actual, expected, + "transpiled Rust disagrees with the interpreter\nsource:\n{src}\nrust:\n{rust}" + ); + expected +} + +#[test] +fn test_optmethods_1_01_some_methods() { + let src = main_of( + r#" let s = Some(3) + println(s.unwrap()) + println(s.expect("need a value")) + println(s.unwrap_or(0)) + println(s.unwrap_or_else(|| 9)) + println(s.unwrap_or_default()) + println(s.is_some()) + println(s.is_none()) + println(s.map(|x| x * 2).unwrap()) + println(s.and_then(|x| Some(x + 1)).unwrap()) + println(s.or(Some(7)).unwrap()) + println(s.filter(|x| x > 1).is_some()) + println(s.filter(|x| x > 5).is_none()) + println(s.ok_or("missing").unwrap())"#, + ); + assert_eq!( + run_file(&src), + lines(&["3", "3", "3", "3", "3", "true", "false", "6", "4", "3", "true", "true", "3"]) + ); +} + +#[test] +fn test_optmethods_1_02_none_methods() { + let src = main_of( + r#" let n = None + println(n.unwrap_or(5)) + println(n.unwrap_or_else(|| 9)) + println(n.is_some()) + println(n.is_none()) + println(n.map(|x| x * 2).is_none()) + println(n.and_then(|x| Some(x)).is_none()) + println(n.or(Some(7)).unwrap()) + println(n.filter(|x| x > 1).is_none()) + println(n.ok_or("missing").is_err())"#, + ); + assert_eq!( + run_file(&src), + lines(&["5", "9", "false", "true", "true", "true", "7", "true", "true"]) + ); +} + +#[test] +fn test_optmethods_1_03_ok_methods() { + let src = main_of( + r#" let r = Ok(2) + println(r.unwrap()) + println(r.expect("need ok")) + println(r.unwrap_or(0)) + println(r.unwrap_or_else(|e| 0)) + println(r.is_ok()) + println(r.is_err()) + println(r.map(|x| x + 1).unwrap()) + println(r.and_then(|x| Ok(x * 10)).unwrap()) + println(r.ok().unwrap()) + println(r.or(Ok(9)).unwrap())"#, + ); + assert_eq!( + run_file(&src), + lines(&["2", "2", "2", "2", "true", "false", "3", "20", "2", "2"]) + ); +} + +#[test] +fn test_optmethods_1_04_err_methods() { + let src = main_of( + r#" let r = Err("bad") + println(r.unwrap_or(0)) + println(r.unwrap_or_else(|e| 1)) + println(r.is_ok()) + println(r.is_err()) + println(r.map(|x| x + 1).is_err()) + println(r.and_then(|x| Ok(x)).is_err()) + println(r.ok().is_none()) + println(r.or(Ok(9)).unwrap())"#, + ); + assert_eq!( + run_file(&src), + lines(&["0", "1", "false", "true", "true", "true", "true", "9"]) + ); +} + +#[test] +fn test_optmethods_1_05_plain_value_is_present() { + let src = main_of( + r#" let x = 5 + println(x.unwrap()) + println(x.expect("need x")) + println(x.unwrap_or(0)) + println(x.is_some()) + println(x.is_none()) + println(x.and_then(|v| v + 1)) + println("hi".unwrap()) + let v = [1, 2, 3] + println(v.first().unwrap())"#, + ); + assert_eq!( + run_file(&src), + lines(&["5", "5", "5", "true", "false", "6", "hi", "1"]) + ); +} + +#[test] +fn test_optmethods_1_06_plain_value_methods_not_shadowed() { + let src = main_of( + r#" let v = [1, 2, 3] + println(v.map(|x| x * 2)) + println(v.filter(|x| x > 1)) + println("abc".len()) + println(7.abs()) + let m = {"a": 1} + println(m.keys())"#, + ); + assert_eq!( + run_file(&src), + lines(&["[2, 4, 6]", "[2, 3]", "3", "7", "[\"a\"]"]) + ); +} + +#[test] +fn test_optmethods_1_07_nil_is_absent() { + let src = main_of( + r#" let mut e = [1] + e.pop() + let p = e.pop() + println(p.is_none()) + println(p.is_some()) + println(p.unwrap_or(4)) + println(p.unwrap_or_else(|| 8)) + println(p.map(|x| x + 1).is_none()) + println(p.or(Some(2)).unwrap())"#, + ); + assert_eq!( + run_file(&src), + lines(&["true", "false", "4", "8", "true", "2"]) + ); +} + +#[test] +fn test_optmethods_1_08_unwrap_on_absent_is_an_error_naming_the_value() { + let none = run_failing(&main_of(" let n = None\n println(n.unwrap())")); + assert!(none.contains("unwrap") && none.contains("None"), "{none}"); + let err = run_failing(&main_of( + " let r = Err(\"boom\")\n println(r.unwrap())", + )); + assert!(err.contains("unwrap") && err.contains("boom"), "{err}"); + let nil = run_failing(&main_of( + " let mut e = []\n let p = e.pop()\n println(p.unwrap())", + )); + assert!(nil.contains("unwrap") && nil.contains("nil"), "{nil}"); +} + +#[test] +fn test_optmethods_1_09_expect_on_absent_includes_the_message() { + let out = run_failing(&main_of( + " let n = None\n println(n.expect(\"value was required\"))", + )); + assert!(out.contains("value was required"), "{out}"); + let err = run_failing(&main_of( + " let r = Err(\"boom\")\n println(r.expect(\"result was required\"))", + )); + assert!( + err.contains("result was required") && err.contains("boom"), + "{err}" + ); +} + +#[test] +fn test_optmethods_1_10_unwrap_or_default_on_absent_is_an_error_not_nil() { + let out = run_failing(&main_of( + " let n = None\n println(n.unwrap_or_default())", + )); + assert!( + out.contains("unwrap_or_default") && out.contains("None") && !out.contains("not found"), + "{out}" + ); +} + +const STACK: &str = "struct Stack { items: Vec } +impl Stack { + fun pop_one(&mut self) -> i32 { + self.items.pop().unwrap() + } +} +"; + +#[test] +fn test_optmethods_1_11_field_pop_unwrap_in_method() { + let src = format!( + "{STACK}fun main() {{ + let mut s = Stack {{ items: vec![1, 2, 3] }} + println(s.pop_one()) + println(s.pop_one()) +}} +" + ); + assert_eq!(run_file(&src), "3\n2\n"); +} + +#[test] +fn test_optmethods_1_12_map_get_on_plain_object() { + let src = main_of( + r#" let m = {"a": 1, "b": 2} + println(m.get("a")) + println(m.get("a").unwrap()) + println(m.get("z").is_none()) + println(m.get("z").unwrap_or(0)) + println(m.values())"#, + ); + assert_eq!(run_file(&src), lines(&["1", "1", "true", "0", "[1, 2]"])); +} + +#[test] +fn test_optmethods_1_13_differential_pop_unwrap_through_rustc() { + let src = format!( + "{STACK}fun main() {{ + let mut s = Stack {{ items: vec![1, 2, 3] }} + println(s.pop_one()) + println(s.pop_one()) + println(s.items.pop().unwrap_or(0)) + println(s.items.pop().unwrap_or(0)) + println(s.items.pop().is_none()) +}} +" + ); + assert_eq!(differential(&src), "3\n2\n1\n0\ntrue\n"); +} + +// ---------- RHLGA-1 review F5: a user enum's `Some`/`None` is not Option ---------- + +#[test] +fn test_optmethods_1_14_user_enum_variant_named_some_keeps_its_methods() { + let src = "enum Maybe { Some(i32), None } +impl Maybe { + fun unwrap(self) -> i32 { + 42 + } + fun is_some(self) -> bool { + false + } +} +fun main() { + let m = Maybe::Some(3) + println(m.unwrap()) + println(m.is_some()) + let n = Maybe::None + println(n.unwrap()) +} +"; + assert_eq!(run_file(src), "42\nfalse\n42\n"); +} diff --git a/tests/parser_defect_010_trait_associated_types.rs b/tests/parser_defect_010_trait_associated_types.rs index dc75a01d9..671c7e4be 100644 --- a/tests/parser_defect_010_trait_associated_types.rs +++ b/tests/parser_defect_010_trait_associated_types.rs @@ -73,7 +73,7 @@ trait Summary { fn summarize_author(&self) -> String fn summarize(&self) -> String { - f"(Read more from {}...)", self.summarize_author()) + f"(Read more from {self.summarize_author()}...)" } } "#; @@ -120,7 +120,7 @@ trait Summary { fn summarize_author(&self) -> String fn summarize(&self) -> String { - f"(Read more from {}...)", self.summarize_author()) + f"(Read more from {self.summarize_author()}...)" } } @@ -147,7 +147,6 @@ trait From { // Test 7: Transpile trait with associated type #[test] -#[ignore = "Trait associated types not fully transpiled yet"] fn test_parser_010_transpile() { let code = r" trait Iterator { diff --git a/tests/quality_001_self_receiver.rs b/tests/quality_001_self_receiver.rs index 7171ef458..94475c098 100644 --- a/tests/quality_001_self_receiver.rs +++ b/tests/quality_001_self_receiver.rs @@ -15,6 +15,19 @@ use ruchy::{Parser, Transpiler}; +/// Compile `rust_code` as a library with rustc inside a scratch directory, so +/// neither the source nor the `.rlib` lands in `/tmp` or the crate root. +fn rustc_lib(rust_code: &str) -> std::io::Result { + let dir = tempfile::tempdir()?; + let src = dir.path().join("lib.rs"); + std::fs::write(&src, rust_code)?; + std::process::Command::new("rustc") + .args(["--crate-type", "lib", "--out-dir"]) + .arg(dir.path()) + .arg(&src) + .output() +} + /// Test 1: Basic &self method - should preserve reference #[test] fn test_quality_001_01_immutable_self_reference() { @@ -88,12 +101,7 @@ pub fn test() -> String { let rust_code = result.unwrap().to_string(); // Verify rustc compilation succeeds - std::fs::write("/tmp/quality_001_02_output.rs", &rust_code).expect("Failed to write test file"); - - let rustc_result = std::process::Command::new("rustc") - .args(["--crate-type", "lib", "/tmp/quality_001_02_output.rs"]) - .output() - .expect("Failed to run rustc"); + let rustc_result = rustc_lib(&rust_code).expect("Failed to run rustc"); if !rustc_result.status.success() { let stderr = String::from_utf8_lossy(&rustc_result.stderr); @@ -258,12 +266,7 @@ impl LambdaRuntime { ); // Verify it compiles - std::fs::write("/tmp/quality_001_06_output.rs", &rust_code).expect("Failed to write test file"); - - let rustc_result = std::process::Command::new("rustc") - .args(["--crate-type", "lib", "/tmp/quality_001_06_output.rs"]) - .output() - .expect("Failed to run rustc"); + let rustc_result = rustc_lib(&rust_code).expect("Failed to run rustc"); if !rustc_result.status.success() { let stderr = String::from_utf8_lossy(&rustc_result.stderr); @@ -431,7 +434,11 @@ mod property_tests { // Must have "self" but NOT "&self" or "&mut self" let has_self = rust_code.contains(&format!("fn {method_name}(self)")) || rust_code.contains(&format!("fn {method_name} (self)")) - || rust_code.contains(&format!("fn {method_name} ( self )")); + || rust_code.contains(&format!("fn {method_name} ( self )")) + // A Rust reserved word is emitted as a raw identifier (RAWIDENT-1). + || rust_code.contains(&format!("fn r#{method_name}(self)")) + || rust_code.contains(&format!("fn r#{method_name} (self)")) + || rust_code.contains(&format!("fn r#{method_name} ( self )")); prop_assert!( has_self, @@ -489,13 +496,8 @@ mod property_tests { let rust_code = result.unwrap().to_string(); - // Write to temp file and verify rustc compilation - let temp_file = format!("/tmp/quality_001_prop_{struct_name}.rs"); - std::fs::write(&temp_file, &rust_code).ok(); - - let rustc_result = std::process::Command::new("rustc") - .args(["--crate-type", "lib", &temp_file]) - .output(); + // Verify rustc compilation in a scratch directory + let rustc_result = rustc_lib(&rust_code); prop_assume!(rustc_result.is_ok()); let output = rustc_result.unwrap(); @@ -506,9 +508,6 @@ mod property_tests { "Property violation: Multiple &self calls cause move error:\n{}", String::from_utf8_lossy(&output.stderr) ); - - // Cleanup - std::fs::remove_file(&temp_file).ok(); } /// Property 5: Mixed receiver types in same impl block @@ -569,7 +568,11 @@ mod property_tests { // For owned self, check the specific method name let has_owned_self = rust_code.contains(&format!("fn {consumer}(self)")) || rust_code.contains(&format!("fn {consumer} (self)")) - || rust_code.contains(&format!("fn {consumer} ( self )")); + || rust_code.contains(&format!("fn {consumer} ( self )")) + // A Rust reserved word is emitted as a raw identifier (RAWIDENT-1). + || rust_code.contains(&format!("fn r#{consumer}(self)")) + || rust_code.contains(&format!("fn r#{consumer} (self)")) + || rust_code.contains(&format!("fn r#{consumer} ( self )")); prop_assert!( has_owned_self, diff --git a/tests/rawident_1_rust_keywords.rs b/tests/rawident_1_rust_keywords.rs new file mode 100644 index 000000000..a2bfe8504 --- /dev/null +++ b/tests/rawident_1_rust_keywords.rs @@ -0,0 +1,170 @@ +//! RAWIDENT-1: a Ruchy identifier that is a Rust reserved word but not a +//! Ruchy keyword (`do`, `box`, `typeof`, ...) used as a function or method +//! NAME must be emitted as a raw identifier (`r#do`) at both the definition +//! and the call site, so rustc accepts the transpiled code. Before the fix, +//! `Transpiler::safe_ident` did not exist and `fn do(&self)` / `obj.do()` +//! were emitted verbatim, which rustc rejects (E0379/E0533-class syntax +//! errors: `do` etc. are Rust keywords even though Ruchy allows them as +//! plain identifiers). +//! +//! This test transpiles a class with methods named `do` and `box` plus a +//! free function named `typeof`, compiles the result with rustc, runs the +//! binary, and checks its stdout matches `ruchy run` on the same source. + +use assert_cmd::Command; +use std::path::Path; +use std::time::Duration; + +/// A struct with two reserved-but-not-Ruchy-keyword method names, plus a +/// free function with a reserved-but-not-Ruchy-keyword name, called from +/// `main`. +const RAWIDENT_SOURCE: &str = r"class Thing { + n: i32 + new(n: i32) { self.n = n } + fun do(&self) -> i32 { self.n } + fun box(&self) -> i32 { self.n * 2 } +} +fun typeof(x: i32) -> i32 { x + 100 } +fun main() { + let t = Thing::new(5) + println(t.do()) + println(t.box()) + println(typeof(t.n)) +} +"; + +fn ruchy() -> Command { + let mut cmd = Command::cargo_bin("ruchy").expect("ruchy binary is built for tests"); + cmd.timeout(Duration::from_secs(30)); + cmd +} + +fn stdout_of(mut cmd: Command, what: &str) -> String { + let out = cmd.output().expect("spawn command"); + assert!( + out.status.success(), + "{what} failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8(out.stdout).expect("utf8 stdout") +} + +fn write_source(dir: &Path, src: &str) -> std::path::PathBuf { + let file = dir.join("prog.ruchy"); + std::fs::write(&file, src).expect("write ruchy source"); + file +} + +/// Test 1: `ruchy run` (interpreter) already prints the expected values; +/// this is the oracle the transpiled binary must match. +#[test] +fn test_rawident_1_01_interpreter_runs_reserved_word_names() { + let dir = tempfile::tempdir().expect("temp dir"); + let file = write_source(dir.path(), RAWIDENT_SOURCE); + let mut cmd = ruchy(); + cmd.arg("run").arg(&file); + assert_eq!(stdout_of(cmd, "ruchy run"), "5\n10\n105\n"); +} + +/// Test 2: the transpiler must emit raw identifiers (`r#do`, `r#box`, +/// `r#typeof`) at both the definition and the call site. +#[test] +fn test_rawident_1_02_transpile_emits_raw_identifiers() { + let dir = tempfile::tempdir().expect("temp dir"); + let file = write_source(dir.path(), RAWIDENT_SOURCE); + let mut cmd = ruchy(); + cmd.arg("transpile").arg(&file); + let rust = stdout_of(cmd, "ruchy transpile"); + + // Definition sites + assert!( + rust.contains("fn r#do"), + "method definition `do` must be raw:\n{rust}" + ); + assert!( + rust.contains("fn r#box"), + "method definition `box` must be raw:\n{rust}" + ); + assert!( + rust.contains("fn r#typeof"), + "function definition `typeof` must be raw:\n{rust}" + ); + + // Call sites + assert!( + rust.contains(".r#do()"), + "method call `.do()` must be raw:\n{rust}" + ); + assert!( + rust.contains(".r#box()"), + "method call `.box()` must be raw:\n{rust}" + ); + assert!( + rust.contains("r#typeof(t.n)") || rust.contains("r#typeof (t . n)"), + "function call `typeof(..)` must be raw:\n{rust}" + ); +} + +/// Test 3: the transpiled program compiles with rustc and, once run, prints +/// exactly what `ruchy run` prints for the same source. +#[test] +fn test_rawident_1_03_transpiled_binary_matches_interpreter() { + let dir = tempfile::tempdir().expect("temp dir"); + let file = write_source(dir.path(), RAWIDENT_SOURCE); + + let mut transpile = ruchy(); + transpile.arg("transpile").arg(&file); + let rust = stdout_of(transpile, "ruchy transpile"); + + let main_rs = dir.path().join("main.rs"); + let bin = dir.path().join("rawident_bin"); + std::fs::write(&main_rs, &rust).expect("write main.rs"); + + let mut rustc = Command::new("rustc"); + rustc + .args(["--edition", "2021", "-A", "warnings", "-o"]) + .arg(&bin) + .arg(&main_rs) + .timeout(Duration::from_secs(120)); + stdout_of(rustc, &format!("rustc on transpiled Rust:\n{rust}\n")); + + let mut run_bin = Command::new(&bin); + run_bin.timeout(Duration::from_secs(30)); + let binary_stdout = stdout_of(run_bin, "transpiled binary"); + + let mut interpreter = ruchy(); + interpreter.arg("run").arg(&file); + let interpreter_stdout = stdout_of(interpreter, "ruchy run"); + + assert_eq!(binary_stdout, interpreter_stdout); + assert_eq!(binary_stdout, "5\n10\n105\n"); +} + +/// Test 4: a reserved word reached through a `::` path (`m::r#do()`) is raw too, +/// so the module path call compiles (review round 2 of G2 part 2). +#[test] +fn test_rawident_1_04_module_path_call_is_raw() { + let dir = tempfile::tempdir().expect("temp dir"); + let file = write_source( + dir.path(), + "mod m {\n pub fun do() -> i32 {\n 7\n }\n}\n\nfun main() {\n println(m::do())\n}\n", + ); + let mut transpile = ruchy(); + transpile.arg("transpile").arg(&file); + let rust = stdout_of(transpile, "ruchy transpile"); + assert!(rust.contains("r#do"), "expected r#do in:\n{rust}"); + + let main_rs = dir.path().join("main.rs"); + let bin = dir.path().join("rawident_path_bin"); + std::fs::write(&main_rs, &rust).expect("write main.rs"); + let mut rustc = Command::new("rustc"); + rustc + .args(["--edition", "2021", "-A", "warnings", "-o"]) + .arg(&bin) + .arg(&main_rs) + .timeout(Duration::from_secs(120)); + stdout_of(rustc, &format!("rustc on transpiled Rust:\n{rust}\n")); + let mut run_bin = Command::new(&bin); + run_bin.timeout(Duration::from_secs(30)); + assert_eq!(stdout_of(run_bin, "transpiled binary"), "7\n"); +} diff --git a/tests/readme_validation.rs b/tests/readme_validation.rs index aba5f75fa..1a50a3127 100644 --- a/tests/readme_validation.rs +++ b/tests/readme_validation.rs @@ -77,17 +77,21 @@ fn test_readme_not_empty() { fn test_readme_required_sections() { let content = fs::read_to_string("README.md").expect("Failed to read README.md"); - let required_sections = vec![ - "# Ruchy", // Title - "## Features", // What it does - "## Installation", // How to install - "## CLI Commands", // How to use + // Each required section with its accepted spellings: the title is an + // HTML `

` in the centered header block, and the CLI + // usage section is headed "Core Commands". + let required_sections: [&[&str]; 4] = [ + &["# Ruchy", ">Ruchy

"], // Title + &["## Features"], // What it does + &["## Installation"], // How to install + &["## CLI Commands", "## Core Commands"], // How to use ]; - for section in required_sections { + for spellings in required_sections { assert!( - content.contains(section), - "README.md must contain section: {section}" + spellings.iter().any(|s| content.contains(s)), + "README.md must contain section: {}", + spellings.join(" or ") ); } } diff --git a/tests/release_hygiene.rs b/tests/release_hygiene.rs index 3f71b7e23..ea5ff5d0d 100644 --- a/tests/release_hygiene.rs +++ b/tests/release_hygiene.rs @@ -21,6 +21,88 @@ fn git_ls_files() -> Vec { .collect() } +/// `git status --porcelain` lines (tracked changes and untracked, non-ignored files). +fn dirty_paths() -> Vec { + let output = Command::new("git") + .args(["status", "--porcelain"]) + .output() + .expect("failed to run git status"); + String::from_utf8_lossy(&output.stdout) + .lines() + .map(std::string::ToString::to_string) + .collect() +} + +/// The failure message for a failed `cargo package --list`. `cargo package` +/// refuses any dirty tree, so a dirty tree is named as the cause explicitly +/// instead of surfacing only cargo's raw error (G2B-S2). `--allow-dirty` is +/// deliberately not used: the check is about what a clean release would ship. +fn package_failure_message(dirty: &[String], cargo_stderr: &str) -> String { + if dirty.is_empty() { + return format!("cargo package --list failed: {cargo_stderr}"); + } + format!( + "working tree is dirty: cargo package refuses to run until these are committed \ + or ignored:\n{}\ncargo said: {cargo_stderr}", + dirty.join("\n") + ) +} + +/// `cargo package --list -p ruchy` stdout; fails naming a dirty tree when that is the cause. +fn cargo_package_list() -> String { + let output = Command::new(env!("CARGO")) + .args(["package", "--list", "-p", "ruchy"]) + .output() + .expect("failed to run cargo package --list"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "{}", + package_failure_message(&dirty_paths(), &stderr) + ); + String::from_utf8_lossy(&output.stdout).into_owned() +} + +#[test] +fn test_g2b_s2_package_failure_names_a_dirty_tree() { + let msg = package_failure_message(&["?? src/.pmat/tdg-cold.db".to_string()], "error: x"); + assert!(msg.starts_with("working tree is dirty"), "{msg}"); + assert!(msg.contains("src/.pmat/tdg-cold.db"), "{msg}"); + assert!(msg.contains("error: x"), "{msg}"); + let clean = package_failure_message(&[], "error: y"); + assert!(!clean.contains("dirty"), "{clean}"); +} + +/// Paths pmat writes while tests and hooks run must be ignored by the ROOT +/// `.gitignore`, or `cargo package` sees a dirty tree (G2B-S2). pmat also drops +/// a `*` `.gitignore` inside each `.pmat/` it creates, but that file is itself +/// untracked and absent in a fresh clone, so it is checked in isolation here: a +/// scratch repository holding only the root `.gitignore`. +#[test] +fn test_g2b_s2_tool_written_paths_are_ignored() { + let scratch = tempfile::TempDir::new().expect("tempdir"); + std::fs::copy(".gitignore", scratch.path().join(".gitignore")).expect("copy .gitignore"); + let git = |args: &[&str]| { + Command::new("git") + .current_dir(scratch.path()) + .args(args) + .status() + .expect("failed to run git") + }; + assert!(git(&["init", "-q"]).success(), "git init failed"); + for path in [ + "src/.pmat/tdg-cold.db", + "src/.pmat/tdg-warm.db", + ".pmat/tdg-cold.db", + ".pmat/jidoka.jsonl", + ".pmat/clippy-receipt", + ".pmat/dead-code-cache-eb-d8.json", + ] { + let ignored = git(&["check-ignore", "--no-index", "-q", path]).success(); + assert!(ignored, "{path} must be ignored by the root .gitignore"); + } +} + #[test] fn test_pmat_092_git_tracked_paths_no_colon() { let files = git_ls_files(); @@ -105,16 +187,7 @@ fn test_pmat_092_pmat_work_colon_dirs_not_tracked() { #[test] fn test_pmat_092_pmat_work_not_in_package_list() { - let output = Command::new(env!("CARGO")) - .args(["package", "--list", "-p", "ruchy"]) - .output() - .expect("failed to run cargo package --list"); - assert!( - output.status.success(), - "cargo package --list failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - let files = String::from_utf8_lossy(&output.stdout); + let files = cargo_package_list(); let offenders: Vec<&str> = files .lines() .filter(|l| l.starts_with(".pmat-work/")) @@ -130,16 +203,7 @@ fn test_pmat_092_pmat_work_not_in_package_list() { /// true so `cargo install ruchy` remains reproducible. #[test] fn test_pmat_092_cargo_lock_present_in_package_list() { - let output = Command::new(env!("CARGO")) - .args(["package", "--list", "-p", "ruchy"]) - .output() - .expect("failed to run cargo package --list"); - assert!( - output.status.success(), - "cargo package --list failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - let files = String::from_utf8_lossy(&output.stdout); + let files = cargo_package_list(); assert!( files.lines().any(|l| l == "Cargo.lock"), "Cargo.lock must be present in the packaged file list" diff --git a/tests/runtime_builtins_core.rs b/tests/runtime_builtins_core.rs index 9a9e396aa..520a41112 100644 --- a/tests/runtime_builtins_core.rs +++ b/tests/runtime_builtins_core.rs @@ -136,12 +136,16 @@ fun main() { } // ============================================================================ -// BUG #2: Cryptic error messages for unsupported Rust syntax -// Bug Report: ../ruchy-cookbook/RUCHY_ISSUE_SYNTAX_ERROR_REPORT.md +// BUG #2 (superseded): `#[derive(...)]` once failed with a cryptic +// "Unexpected token: AttributeStart", and this test then demanded an +// "Attributes are not supported" error. The 5.0 spec (ruchy-5.0-sovereign-platform.md +// section 3 grammar: attribute ::= '#[' IDENT ( '(' arg_list ')' )? ']') and +// PARSER-ATTR-001 (tests/parser_attribute_syntax.rs) make Rust-style attributes +// valid Ruchy, so the correct behaviour is: accepted, and carried to the Rust output. // ============================================================================ #[test] -fn test_bug_syntax_001_attribute_error_message() { +fn test_bug_syntax_001_rust_style_derive_attribute_accepted() { let code = r" #[derive(Debug)] struct Point { @@ -150,15 +154,16 @@ struct Point { } "; - let output = ruchy_cmd().arg("-e").arg(code).assert().failure(); + ruchy_cmd().arg("-e").arg(code).assert().success(); - // Should have helpful error message, not cryptic "Unexpected token: AttributeStart" - let stderr = String::from_utf8_lossy(&output.get_output().stderr); + let dir = tempfile::tempdir().expect("tempdir"); + let src = dir.path().join("derive.ruchy"); + std::fs::write(&src, code).expect("write source"); + let output = ruchy_cmd().arg("transpile").arg(&src).assert().success(); + let stdout = String::from_utf8_lossy(&output.get_output().stdout); assert!( - stderr.contains("Attributes are not supported") - || stderr.contains("does not support #[derive]") - || stderr.contains("Ruchy does not use Rust-style attributes"), - "Error message should explain that attributes are not supported. Got: {stderr}" + stdout.contains("#[derive(Debug") && stdout.contains("struct Point"), + "derive attribute must reach the Rust output. Got: {stdout}" ); } diff --git a/tests/sqlite_001_parser_grammar.rs b/tests/sqlite_001_parser_grammar.rs index d18b1d157..f3ae5a456 100644 --- a/tests/sqlite_001_parser_grammar.rs +++ b/tests/sqlite_001_parser_grammar.rs @@ -8280,12 +8280,13 @@ fn test_sqlite_162_where_clauses() { #[test] fn test_sqlite_163_associated_types() { - // Associated types + // Associated types, in SPECIFICATION.md syntax (`-> Option`, cf. §actors + // `-> Option`); a `): T` return type is not Ruchy syntax. assert_parses( r" trait Iterator { type Item - fun next(self): Option + fun next(self) -> Option } ", ); diff --git a/tests/tooling_002_renacer_integration.rs b/tests/tooling_002_renacer_integration.rs index d21670e2b..7c729b278 100644 --- a/tests/tooling_002_renacer_integration.rs +++ b/tests/tooling_002_renacer_integration.rs @@ -36,12 +36,26 @@ fn test_tooling_002_01_renacer_installed() { stdout.contains("renacer"), "renacer version output should contain 'renacer'" ); + // Golden traces were captured with renacer 0.6.2; later releases read the + // same traces (tests 2-N pass on 0.10.x), so 0.6 is a floor, not a pin. assert!( - stdout.contains("0.6"), - "renacer should be v0.6.x, got: {stdout}" + renacer_version_at_least(&stdout, (0, 6)), + "renacer should be v0.6 or newer, got: {stdout}" ); } +/// Parse `renacer X.Y.Z` and compare (major, minor) against `floor`. +fn renacer_version_at_least(version_output: &str, floor: (u64, u64)) -> bool { + let Some(version) = version_output.split_whitespace().nth(1) else { + return false; + }; + let mut parts = version.split('.').map(|p| p.parse::().ok()); + match (parts.next().flatten(), parts.next().flatten()) { + (Some(major), Some(minor)) => (major, minor) >= floor, + _ => false, + } +} + /// Test 2: Verify golden-traces directory exists #[test] fn test_tooling_002_02_golden_traces_dir_exists() { diff --git a/tests/transpiler_001_integer_arithmetic.rs b/tests/transpiler_001_integer_arithmetic.rs index fa46ab160..dc9601efc 100644 --- a/tests/transpiler_001_integer_arithmetic.rs +++ b/tests/transpiler_001_integer_arithmetic.rs @@ -51,6 +51,9 @@ pub fn add(a: i32, b: i32) -> i32 { .expect("Failed to write test file"); let rustc_result = std::process::Command::new("rustc") + // COMPILERACE-1: the .rlib goes to the temp dir, never the crate root. + .arg("--out-dir") + .arg(std::env::temp_dir()) .args(["--crate-type", "lib", "/tmp/transpiler_001_01_output.rs"]) .output() .expect("Failed to run rustc"); @@ -168,6 +171,9 @@ pub fn calculate(a: i32, b: i32, c: i32) -> i32 { .expect("Failed to write test file"); let rustc_result = std::process::Command::new("rustc") + // COMPILERACE-1: the .rlib goes to the temp dir, never the crate root. + .arg("--out-dir") + .arg(std::env::temp_dir()) .args(["--crate-type", "lib", "/tmp/transpiler_001_05_output.rs"]) .output() .expect("Failed to run rustc"); @@ -218,6 +224,9 @@ impl Counter { .expect("Failed to write test file"); let rustc_result = std::process::Command::new("rustc") + // COMPILERACE-1: the .rlib goes to the temp dir, never the crate root. + .arg("--out-dir") + .arg(std::env::temp_dir()) .args(["--crate-type", "lib", "/tmp/transpiler_001_06_output.rs"]) .output() .expect("Failed to run rustc"); @@ -251,6 +260,9 @@ pub fn concat(a: String, b: String) -> String { .expect("Failed to write test file"); let rustc_result = std::process::Command::new("rustc") + // COMPILERACE-1: the .rlib goes to the temp dir, never the crate root. + .arg("--out-dir") + .arg(std::env::temp_dir()) .args(["--crate-type", "lib", "/tmp/transpiler_001_07_output.rs"]) .output() .expect("Failed to run rustc"); @@ -289,6 +301,9 @@ pub fn increment_by(mut value: i32, amount: i32) -> i32 { .expect("Failed to write test file"); let rustc_result = std::process::Command::new("rustc") + // COMPILERACE-1: the .rlib goes to the temp dir, never the crate root. + .arg("--out-dir") + .arg(std::env::temp_dir()) .args(["--crate-type", "lib", "/tmp/transpiler_001_08_output.rs"]) .output() .expect("Failed to run rustc"); diff --git a/tests/transpiler_006_time_micros.rs b/tests/transpiler_006_time_micros.rs index 13bbaa433..461eb4189 100644 --- a/tests/transpiler_006_time_micros.rs +++ b/tests/transpiler_006_time_micros.rs @@ -42,6 +42,9 @@ pub fn get_time() -> u64 { .expect("Failed to write test file"); let rustc_result = std::process::Command::new("rustc") + // COMPILERACE-1: the .rlib goes to the temp dir, never the crate root. + .arg("--out-dir") + .arg(std::env::temp_dir()) .args(["--crate-type", "lib", "/tmp/transpiler_006_01_output.rs"]) .output() .expect("Failed to run rustc"); @@ -81,6 +84,9 @@ pub fn benchmark() -> u64 { .expect("Failed to write test file"); let rustc_result = std::process::Command::new("rustc") + // COMPILERACE-1: the .rlib goes to the temp dir, never the crate root. + .arg("--out-dir") + .arg(std::env::temp_dir()) .args(["--crate-type", "lib", "/tmp/transpiler_006_02_output.rs"]) .output() .expect("Failed to run rustc"); @@ -128,6 +134,9 @@ fn fib(n: i32) -> i32 { .expect("Failed to write test file"); let rustc_result = std::process::Command::new("rustc") + // COMPILERACE-1: the .rlib goes to the temp dir, never the crate root. + .arg("--out-dir") + .arg(std::env::temp_dir()) .args(["--crate-type", "lib", "/tmp/transpiler_006_03_output.rs"]) .output() .expect("Failed to run rustc"); @@ -172,6 +181,9 @@ pub fn multi_benchmark() -> (u64, u64) { .expect("Failed to write test file"); let rustc_result = std::process::Command::new("rustc") + // COMPILERACE-1: the .rlib goes to the temp dir, never the crate root. + .arg("--out-dir") + .arg(std::env::temp_dir()) .args(["--crate-type", "lib", "/tmp/transpiler_006_04_output.rs"]) .output() .expect("Failed to run rustc"); diff --git a/tests/transpiler_008_pub_visibility.rs b/tests/transpiler_008_pub_visibility.rs index c02e6dd7e..8faa8ba8a 100644 --- a/tests/transpiler_008_pub_visibility.rs +++ b/tests/transpiler_008_pub_visibility.rs @@ -62,6 +62,9 @@ impl Library { .expect("Failed to write test file"); let rustc_result = std::process::Command::new("rustc") + // COMPILERACE-1: the .rlib goes to the temp dir, never the crate root. + .arg("--out-dir") + .arg(std::env::temp_dir()) .args(["--crate-type", "lib", "/tmp/transpiler_008_01_output.rs"]) .output() .expect("Failed to run rustc"); @@ -205,6 +208,9 @@ impl Calculator { .expect("Failed to write test file"); let rustc_result = std::process::Command::new("rustc") + // COMPILERACE-1: the .rlib goes to the temp dir, never the crate root. + .arg("--out-dir") + .arg(std::env::temp_dir()) .args(["--crate-type", "lib", "/tmp/transpiler_008_04_output.rs"]) .output() .expect("Failed to run rustc"); diff --git a/tests/transpiler_deadlock_fix.rs b/tests/transpiler_deadlock_fix.rs index 13037ee51..1d638caf9 100644 --- a/tests/transpiler_deadlock_fix.rs +++ b/tests/transpiler_deadlock_fix.rs @@ -267,6 +267,13 @@ mod property_tests { use super::*; use proptest::prelude::*; + /// A generated name is only a variable name if the lexer reads it as an + /// identifier: `[a-z]{3,8}` also yields keywords such as `fun` or `let`. + fn is_plain_identifier(name: &str) -> bool { + let mut tokens = ruchy::TokenStream::new(name); + matches!(tokens.next(), Some((ruchy::Token::Identifier(_), _))) && tokens.next().is_none() + } + /// Property: Any global variable assignment should not deadlock /// Strategy: Generate random variable names and values #[test] @@ -276,6 +283,7 @@ mod property_tests { init_val in 0i32..100, op_val in 1i32..50, )| { + prop_assume!(is_plain_identifier(&var_name)); let code = format!( r#" let mut {var_name} = {init_val} @@ -341,6 +349,7 @@ println!("{{}}", {var_name}) var_name in "[a-z]{3,8}", init_val in 0i32..100, )| { + prop_assume!(is_plain_identifier(&var_name)); // Use fixed value to avoid return type inference bug let code = format!( r#" diff --git a/tests/transpiler_scope_global_mut.rs b/tests/transpiler_scope_global_mut.rs index c20a498e0..946174445 100644 --- a/tests/transpiler_scope_global_mut.rs +++ b/tests/transpiler_scope_global_mut.rs @@ -81,16 +81,20 @@ increment(); println!("{}", counter); "#; - let temp_file = NamedTempFile::new().unwrap(); - let ruchy_path = temp_file.path().with_extension("ruchy"); + // RHLGA-1 F8: compile inside a temp dir, so the default `a.out` (and any + // other compiler output) lands there and never in the repository. + let dir = tempfile::tempdir().unwrap(); + let ruchy_path = dir.path().join("prog.ruchy"); fs::write(&ruchy_path, code).unwrap(); // CRITICAL: Must compile successfully let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("ruchy"); - cmd.arg("compile").arg(&ruchy_path).assert().success(); // ❌ This will FAIL until we fix the bug - - // Clean up - let _ = fs::remove_file(&ruchy_path); + cmd.current_dir(dir.path()) + .arg("compile") + .arg(&ruchy_path) + .assert() + .success(); + assert!(dir.path().join("a.out").exists(), "a.out in the temp dir"); } /// Test 3: Full execution via ruchy run command diff --git a/tests/transpiler_statements_comprehensive.rs b/tests/transpiler_statements_comprehensive.rs index 8282e0c40..1d2d78d01 100644 --- a/tests/transpiler_statements_comprehensive.rs +++ b/tests/transpiler_statements_comprehensive.rs @@ -55,6 +55,9 @@ pub fn test() { .expect("Failed to write test file"); let rustc_result = Command::new("rustc") + // COMPILERACE-1: the .rlib goes to the temp dir, never the crate root. + .arg("--out-dir") + .arg(std::env::temp_dir()) .args([ "--crate-type", "lib", @@ -171,6 +174,9 @@ pub fn test(x: i32) -> String { .expect("Failed to write test file"); let rustc_result = Command::new("rustc") + // COMPILERACE-1: the .rlib goes to the temp dir, never the crate root. + .arg("--out-dir") + .arg(std::env::temp_dir()) .args([ "--crate-type", "lib", @@ -292,6 +298,9 @@ pub fn test(x: i32) -> String { .expect("Failed to write test file"); let rustc_result = Command::new("rustc") + // COMPILERACE-1: the .rlib goes to the temp dir, never the crate root. + .arg("--out-dir") + .arg(std::env::temp_dir()) .args([ "--crate-type", "lib", @@ -360,6 +369,9 @@ pub fn test() { .expect("Failed to write test file"); let rustc_result = Command::new("rustc") + // COMPILERACE-1: the .rlib goes to the temp dir, never the crate root. + .arg("--out-dir") + .arg(std::env::temp_dir()) .args([ "--crate-type", "lib", @@ -705,6 +717,9 @@ pub fn calculate(x: i32) -> i32 { .expect("Failed to write test file"); let rustc_result = Command::new("rustc") + // COMPILERACE-1: the .rlib goes to the temp dir, never the crate root. + .arg("--out-dir") + .arg(std::env::temp_dir()) .args([ "--crate-type", "lib", diff --git a/tests/unitret_1_unit_body_return.rs b/tests/unitret_1_unit_body_return.rs new file mode 100644 index 000000000..9ed1e7f99 --- /dev/null +++ b/tests/unitret_1_unit_body_return.rs @@ -0,0 +1,200 @@ +//! UNITRET-1: a function without a declared return type whose tail +//! expression is a method call that returns unit in Rust (`push`, `clear`, +//! `sort`, ...) must not get an inferred return type. +//! +//! Before the fix `fun f(xs: &mut Vec) { xs.push(1) }` transpiled to +//! `fn f(xs: &mut Vec) -> i32 { xs.push(1) }` and rustc failed with +//! E0308 (expected `i32`, found `()`). +//! +//! Bodies with a value-producing tail keep their inferred return type. + +use assert_cmd::Command; +use std::path::Path; +use std::time::Duration; + +fn ruchy() -> Command { + let mut cmd = Command::cargo_bin("ruchy").expect("ruchy binary is built for tests"); + cmd.timeout(Duration::from_secs(10)); + cmd +} + +fn write_source(dir: &Path, src: &str) -> std::path::PathBuf { + let file = dir.join("prog.ruchy"); + std::fs::write(&file, src).expect("write ruchy source"); + file +} + +fn stdout_of(mut cmd: Command, what: &str) -> String { + let out = cmd.output().expect("spawn command"); + assert!( + out.status.success(), + "{what} failed: {}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8(out.stdout).expect("utf8 stdout") +} + +fn transpile(src: &str) -> String { + let dir = tempfile::tempdir().expect("tempdir"); + let file = write_source(dir.path(), src); + let mut cmd = ruchy(); + cmd.arg("transpile").arg(file); + stdout_of(cmd, "ruchy transpile") +} + +/// `ruchy compile` (rustc) the source, then run the binary. +fn compile_and_run(src: &str) -> String { + let dir = tempfile::tempdir().expect("tempdir"); + let file = write_source(dir.path(), src); + let bin = dir.path().join("prog_bin"); + let mut cmd = ruchy(); + cmd.arg("compile") + .arg(&file) + .arg("-o") + .arg(&bin) + .timeout(Duration::from_secs(60)); + stdout_of(cmd, &format!("ruchy compile of:\n{src}\n")); + let mut run = Command::new(&bin); + run.timeout(Duration::from_secs(30)); + stdout_of(run, "compiled binary") +} + +/// Strip whitespace so `fn f ( x ) -> i32` and `fn f(x)->i32` compare equal. +fn squash(s: &str) -> String { + s.chars().filter(|c| !c.is_whitespace()).collect() +} + +/// The transpiled signature of `f`, from `fn f` up to the body brace. +fn signature_of_f(rust: &str) -> String { + let flat = squash(rust); + let start = flat.find("fnf(").expect("fn f in transpiled output"); + let rest = &flat[start..]; + let end = rest.find('{').expect("body brace after fn f"); + rest[..end].to_string() +} + +/// Unit-returning `Vec` method calls used as a function's tail expression. +const UNIT_TAILS: &[&str] = &[ + "xs.push(1)", + "xs.insert(0, 7)", + "xs.extend(vec![4, 5])", + "xs.clear()", + "xs.sort()", + "xs.reverse()", + "xs.truncate(1)", + "xs.retain(|x| *x > 1)", + "xs.dedup()", + "xs.swap(0, 1)", +]; + +#[test] +fn test_unitret_1_unit_method_tail_has_no_return_type() { + for tail in UNIT_TAILS { + let src = format!("fun f(xs: &mut Vec) {{ {tail} }}\n"); + let sig = signature_of_f(&transpile(&src)); + assert!( + !sig.contains("->"), + "`{tail}` returns unit; f must not infer a return type: {sig}" + ); + } +} + +#[test] +fn test_unitret_1_push_tail_compiles_and_runs() { + let src = "fun f(xs: &mut Vec) { xs.push(1) }\n\ + fun main() { let mut v: Vec = vec![]\n f(&mut v)\n f(&mut v)\n println!(\"{}\", v.len()) }\n"; + assert_eq!(compile_and_run(src).trim(), "2"); +} + +#[test] +fn test_unitret_1_every_unit_tail_compiles() { + let fns: String = UNIT_TAILS + .iter() + .enumerate() + .map(|(i, tail)| format!("fun f{i}(xs: &mut Vec) {{ {tail} }}\n")) + .collect(); + // Called last to first so `swap` runs before `clear` empties the Vec. + let calls: String = (0..UNIT_TAILS.len()) + .rev() + .map(|i| format!(" f{i}(&mut v)\n")) + .collect(); + let src = format!( + "{fns}fun main() {{ let mut v: Vec = vec![3, 1, 2]\n{calls} println!(\"{{:?}}\", v) }}\n" + ); + assert_eq!(compile_and_run(&src).trim(), "[7, 4, 5, 1]"); +} + +#[test] +fn test_unitret_1_unit_tail_after_statements() { + let src = "fun add_two(xs: &mut Vec) { let n = xs.len()\n xs.push(1)\n xs.push(2) }\n\ + fun main() { let mut v: Vec = vec![]\n add_two(&mut v)\n println!(\"{:?}\", v) }\n"; + assert_eq!(compile_and_run(src).trim(), "[1, 2]"); +} + +#[test] +fn test_unitret_1_unit_tail_in_if_branches() { + let src = + "fun f(xs: &mut Vec, grow: bool) { if grow { xs.push(9) } else { xs.clear() } }\n"; + let sig = signature_of_f(&transpile(src)); + assert!(!sig.contains("->"), "both branches are unit: {sig}"); +} + +#[test] +fn test_unitret_1_arithmetic_tail_keeps_return_type() { + let src = "fun f(a: i32, b: i32) { a + b }\nfun main() { println!(\"{}\", f(2, 3)) }\n"; + assert!(signature_of_f(&transpile(src)).contains("->i32")); + assert_eq!(compile_and_run(src).trim(), "5"); +} + +#[test] +fn test_unitret_1_value_method_tail_keeps_return_type() { + let src = "fun f(x: i32) { x.abs() }\nfun main() { println!(\"{}\", f(-4)) }\n"; + assert!(signature_of_f(&transpile(src)).contains("->i32")); + assert_eq!(compile_and_run(src).trim(), "4"); +} + +#[test] +fn test_unitret_1_len_tail_keeps_a_return_type() { + let src = "fun f(xs: &Vec) { xs.len() }\n"; + assert!(signature_of_f(&transpile(src)).contains("->")); +} + +// ==================== LENRET-1: usize-valued tails ==================== +// `len()`/`count()` are `usize` in the transpiled Rust (an unannotated +// `let n = v.len()` is a `usize` too), so a function whose tail is one of +// them returns `usize` rather than an `i32` it cannot produce. + +#[test] +fn test_lenret_1_len_tail_infers_usize() { + let src = "fun f(xs: &Vec) { xs.len() }\n\ + fun main() { let v = vec![1, 2, 3]\n println!(\"{}\", f(&v)) }\n"; + assert!(signature_of_f(&transpile(src)).contains("->usize")); + assert_eq!(compile_and_run(src).trim(), "3"); +} + +#[test] +fn test_lenret_1_len_tail_after_statements_and_in_loop_bound() { + let src = "fun f(xs: &Vec) { let k = 1\n xs.len() }\n\ + fun main() { let v = vec![4, 5]\n let mut t = 0\n \ + for i in 0..f(&v) { t = t + v[i] }\n println!(\"{} {}\", f(&v), t) }\n"; + assert_eq!(compile_and_run(src).trim(), "2 9"); +} + +#[test] +fn test_lenret_1_string_len_tail_compiles() { + let src = "fun f(s: &str) { s.len() }\nfun main() { println!(\"{}\", f(\"abcd\")) }\n"; + assert!(signature_of_f(&transpile(src)).contains("->usize")); + assert_eq!(compile_and_run(src).trim(), "4"); +} + +/// `xs.count()` transpiles to `xs.iter().count()`. (`s.chars().count()` is +/// emitted as `s.chars().iter().count()`, a separate method-emission defect +/// reported with this ticket, so it is not used here.) +#[test] +fn test_lenret_1_count_tail_compiles() { + let src = "fun f(xs: &Vec) { xs.count() }\n\ + fun main() { println!(\"{}\", f(&vec![7, 8, 9])) }\n"; + assert!(signature_of_f(&transpile(src)).contains("->usize")); + assert_eq!(compile_and_run(src).trim(), "3"); +}