diff --git a/packages/scratch-gui/src/lib/dncl/dncl-identifier-converter.js b/packages/scratch-gui/src/lib/dncl/dncl-identifier-converter.js index 78a53f9f391..d71d90a09ef 100644 --- a/packages/scratch-gui/src/lib/dncl/dncl-identifier-converter.js +++ b/packages/scratch-gui/src/lib/dncl/dncl-identifier-converter.js @@ -8,7 +8,7 @@ import { isFunctionParam, mapVarName, } from './dncl-state' -import { skipString } from './paren-utils' +import { skipString, wrapIntegerDivisions } from './paren-utils' /** * Check if a character is the start of an identifier. @@ -76,6 +76,9 @@ const convertOperators = (segment) => { (_, left, right) => `(${left} / ${right}).to_i`, ) + // DNCL `÷` is the explicit "商の整数値を返す" operator — normalize to `/`. + // Both `÷` and bare `/` are treated as integer division (E in #665 follow-up): + // `wrapIntegerDivisions` below adds the `.to_i` truncation in a single pass. result = result.replace(/÷/g, '/') result = result.replace(/≦/g, '<=') result = result.replace(/≧/g, '>=') @@ -89,6 +92,12 @@ const convertOperators = (segment) => { // でない is postfix: "expr でない" → "!expr" result = result.replace(/(\S+)\s+でない/g, (_, expr) => `!${expr}`) + // DNCL convention: `/` (and `÷`, normalized to `/` above) is integer + // division — emulate Ruby's int/int = int truncation by wrapping each + // top-level `/` with `.to_i`. The Ruby-side blocks converter maps + // `.to_i` to the floor mathop block for actual runtime truncation. + result = wrapIntegerDivisions(result) + return result } diff --git a/packages/scratch-gui/src/lib/dncl/paren-utils.js b/packages/scratch-gui/src/lib/dncl/paren-utils.js index e224f39f139..fe08aed573a 100644 --- a/packages/scratch-gui/src/lib/dncl/paren-utils.js +++ b/packages/scratch-gui/src/lib/dncl/paren-utils.js @@ -144,4 +144,271 @@ const splitArgsAtTopLevel = (args) => { return parts } -export { skipString, findMatchingClose, replaceCall, splitArgsAtTopLevel } +/** + * Wrap every top-level `/` in `line` with `.to_i` so that DNCL division + * truncates like Ruby integer-division. Skips occurrences inside string + * literals and inside the `//` integer-divide alias (which is handled + * elsewhere). Operands are determined by walking outward across balanced + * parens / brackets and stopping at lower-precedence boundaries (operators, + * commas, assignment, statement edges). + * + * Examples: + * `a / b` → `(a / b).to_i` + * `(a + b) / c` → `((a + b) / c).to_i` + * `a + b / c` → `a + (b / c).to_i` + * `Data[i + 1] / 2` → `(Data[i + 1] / 2).to_i` + * `a / b * c` → `(a / b).to_i * c` + * @param {string} line - A Ruby-form line (post DNCL transforms). + * @returns {string} The line with every `/` wrapped in `.to_i`. + */ +const wrapIntegerDivisions = (line) => { + // Operator characters that, if they appear as the FIRST char of the + // captured left operand, indicate we walked into a no-op sequence — + // skip wrapping in that case. `(` / `[` / `{` are NOT boundaries because + // a parenthesized / indexed expression is a valid left operand. + const INVALID_LEFT_FIRST = new Set([ + '+', + '-', + '*', + '/', + '%', + '<', + '>', + '=', + '!', + '&', + '|', + '?', + ':', + ',', + ';', + '\n', + ]) + + // Walk LEFT from `endExclusive - 1` in `s` to find the start of the + // operand. Skips balanced (), [], {} and string literals. + const findLeftOperandStart = (s, endExclusive) => { + let i = endExclusive - 1 + while (i >= 0 && /\s/.test(s[i])) i-- + if (i < 0) return null + while (i >= 0) { + const ch = s[i] + if (ch === ')' || ch === ']' || ch === '}') { + const close = ch + const open = close === ')' ? '(' : close === ']' ? '[' : '{' + let depth = 1 + i-- + while (i >= 0 && depth > 0) { + const c = s[i] + if (c === '"' || c === "'") { + // Skip string backwards. + i-- + while (i >= 0 && s[i] !== c) { + if (i > 0 && s[i - 1] === '\\') i-- + i-- + } + i-- + continue + } + if (c === close) depth++ + else if (c === open) depth-- + i-- + } + // i is one before the matching open; continue back to absorb + // any preceding identifier (function/array name) or chained dot. + continue + } + if (/[\w@$.]/.test(ch)) { + i-- + continue + } + // Stop at boundary or whitespace before boundary. + break + } + // i is one before the operand's first char. + let start = i + 1 + // Trim leading whitespace. + while (start < endExclusive && /\s/.test(s[start])) start++ + return start + } + + // Walk RIGHT from `startInclusive` in `s` to find the index just past + // the end of the operand. Skips balanced parens / strings. + const findRightOperandEnd = (s, startInclusive) => { + let i = startInclusive + while (i < s.length && /\s/.test(s[i])) i++ + if (i >= s.length) return null + // Optional unary +/- sign. + if (s[i] === '+' || s[i] === '-') i++ + while (i < s.length) { + const ch = s[i] + if (ch === '(' || ch === '[' || ch === '{') { + const open = ch + const close = open === '(' ? ')' : open === '[' ? ']' : '}' + let depth = 1 + i++ + while (i < s.length && depth > 0) { + const c = s[i] + if (c === '"' || c === "'") { + i = skipString(s, i, c) + continue + } + if (c === open) depth++ + else if (c === close) depth-- + i++ + } + continue + } + if (ch === '"' || ch === "'") { + i = skipString(s, i, ch) + continue + } + if (/[\w@$.]/.test(ch)) { + i++ + continue + } + break + } + return i + } + + // Iteratively wrap each `/` (left-to-right). After each wrap, the + // result string changes length so we re-scan from the start. + let s = line + let i = 0 + while (i < s.length) { + const ch = s[i] + if (ch === '"' || ch === "'") { + i = skipString(s, i, ch) + continue + } + if (ch === '#') { + // Rest of line is a comment. + break + } + if (ch === '/' && s[i - 1] !== '/' && s[i + 1] !== '/') { + // Skip if this `/` is already inside a `(... / ...).to_i` we + // produced earlier — detect by checking the trailing `.to_i`. + // We only care that the left/right operand isn't itself a + // freshly-generated wrapper. + const leftStart = findLeftOperandStart(s, i) + const rightEnd = findRightOperandEnd(s, i + 1) + if (leftStart === null || rightEnd === null || leftStart >= i) { + i++ + continue + } + const leftOperand = s.substring(leftStart, i).trimEnd() + const rightOperand = s.substring(i + 1, rightEnd).trimStart() + // Skip if leftOperand is empty or starts with a non-operand char. + if (!leftOperand || INVALID_LEFT_FIRST.has(leftOperand[0])) { + i++ + continue + } + // Already wrapped? Detect the surrounding `(... / ...).to_i` shape + // produced by an earlier pass (or by the `//` rewrite upstream) so we + // don't emit `((... / ...).to_i).to_i`. + const CLOSE_PAREN_TO_I = ').to_i' + if ( + leftStart > 0 && + s[leftStart - 1] === '(' && + s.substring(rightEnd, rightEnd + CLOSE_PAREN_TO_I.length) === + CLOSE_PAREN_TO_I + ) { + i = rightEnd + CLOSE_PAREN_TO_I.length + continue + } + const replacement = `(${leftOperand} / ${rightOperand}).to_i` + s = s.substring(0, leftStart) + replacement + s.substring(rightEnd) + // Resume scanning AFTER the inserted `.to_i` so we don't re-process + // the same `/`. + i = leftStart + replacement.length + continue + } + i++ + } + return s +} + +/** + * Inverse of `wrapIntegerDivisions`: strip `(EXPR).to_i` when EXPR contains + * a top-level `/`. DNCL division is integer division by convention so the + * explicit `.to_i` is redundant — preserving it would surface as + * `整数((... / ...))` after the standard `.to_i → 整数()` rewrite, which + * obscures the original intent. + * + * Examples: + * `(a / b).to_i` → `a / b` + * `(Data[i] / 2).to_i` → `Data[i] / 2` + * `(answer).to_i` → `(answer).to_i` (no `/`, untouched) + * `(a + (b / c).to_i).to_i` → `(a + (b / c).to_i).to_i` (outer EXPR + * contains no top-level `/`; the inner one is reachable on its own and + * gets stripped on a subsequent pass) + * @param {string} line - Ruby-form line. + * @returns {string} Line with redundant int-division `.to_i` wrappers stripped. + */ +const stripIntegerDivisionToI = (line) => { + const containsTopLevelSlash = (s) => { + let depth = 0 + let i = 0 + while (i < s.length) { + const ch = s[i] + if (ch === '"' || ch === "'") { + i = skipString(s, i, ch) + continue + } + if (ch === '(' || ch === '[' || ch === '{') depth++ + else if (ch === ')' || ch === ']' || ch === '}') depth-- + else if ( + ch === '/' && + depth === 0 && + s[i - 1] !== '/' && + s[i + 1] !== '/' + ) { + return true + } + i++ + } + return false + } + + let result = '' + let i = 0 + while (i < line.length) { + const ch = line[i] + if (ch === '"' || ch === "'") { + const end = skipString(line, i, ch) + result += line.substring(i, end) + i = end + continue + } + if (ch === '(') { + const close = findMatchingClose(line, i) + const TO_I_SUFFIX = '.to_i' + if ( + close !== -1 && + line.substring(close + 1, close + 1 + TO_I_SUFFIX.length) === + TO_I_SUFFIX + ) { + const inner = line.substring(i + 1, close) + if (containsTopLevelSlash(inner)) { + // Recursively strip nested wrappers in `inner` before emitting. + result += stripIntegerDivisionToI(inner) + i = close + 1 + TO_I_SUFFIX.length // past `).to_i` + continue + } + } + } + result += ch + i++ + } + return result +} + +export { + skipString, + findMatchingClose, + replaceCall, + splitArgsAtTopLevel, + wrapIntegerDivisions, + stripIntegerDivisionToI, +} diff --git a/packages/scratch-gui/src/lib/dncl/ruby-to-dncl-builtins.js b/packages/scratch-gui/src/lib/dncl/ruby-to-dncl-builtins.js index acb46504817..e3b555a3897 100644 --- a/packages/scratch-gui/src/lib/dncl/ruby-to-dncl-builtins.js +++ b/packages/scratch-gui/src/lib/dncl/ruby-to-dncl-builtins.js @@ -5,6 +5,7 @@ import { replaceCall, skipString, splitArgsAtTopLevel, + stripIntegerDivisionToI, } from './paren-utils' import { ID } from './ruby-to-dncl-identifier' @@ -244,6 +245,12 @@ const convertBuiltins = (line) => { (_, expr, sub) => `含む(${expr}, ${sub})`, ) + // Strip `(EXPR / EXPR).to_i` patterns first — DNCL `/` already truncates, + // so the wrapper is the inverse of the int-division wrap performed during + // DNCL → Ruby conversion. Without this, the `(a / b).to_i` round-trips as + // `整数((a / b))` which obscures the original DNCL. + result = stripIntegerDivisionToI(result) + // Postfix-no-args methods. expr is matched as a word identifier; nested // calls like `rand(...).to_i` are not handled (known limitation). result = result.replace( diff --git a/packages/scratch-gui/src/lib/ruby-generator/operators-math-gen.js b/packages/scratch-gui/src/lib/ruby-generator/operators-math-gen.js index 72247e3fbd2..964c9604945 100644 --- a/packages/scratch-gui/src/lib/ruby-generator/operators-math-gen.js +++ b/packages/scratch-gui/src/lib/ruby-generator/operators-math-gen.js @@ -96,10 +96,17 @@ export default function (Generator) { const order = Generator.ORDER_FUNCTION_CALL; const num = Generator.valueToCode(block, 'NUM', Generator.ORDER_NONE) || '0'; const operator = Generator.getFieldValue(block, 'OPERATOR') || null; + const comment = Generator.getCommentText(block); switch (operator) { case 'abs': return [`${num}.abs`, order]; case 'floor': + // `.to_i` and `.floor` both compile to operator_mathop(floor); + // the `@ruby:method:to_i` marker distinguishes the two on + // round-trip so we restore the source method name. + if (comment === '@ruby:method:to_i') { + return [`${num}.to_i`, order]; + } return [`${num}.floor`, order]; case 'ceiling': return [`${num}.ceil`, order]; diff --git a/packages/scratch-gui/src/lib/ruby-to-blocks-converter/operators.js b/packages/scratch-gui/src/lib/ruby-to-blocks-converter/operators.js index e6be2ec85e7..1fd1239fc12 100644 --- a/packages/scratch-gui/src/lib/ruby-to-blocks-converter/operators.js +++ b/packages/scratch-gui/src/lib/ruby-to-blocks-converter/operators.js @@ -365,13 +365,17 @@ const OperatorsConverter = { converter.registerOnSend(['variable', 'number', 'string', 'block'], 'to_i', 0, params => { const {receiver} = params; - const block = converter._createBlock('operator_add', 'value'); + // Map `.to_i` to `operator_mathop(floor, x)` so the runtime + // actually truncates (the previous `operator_add(x, 0)` was a + // pass-through). The `@ruby:method:to_i` marker lets the + // generator emit `.to_i` instead of `.floor` on round-trip. + const block = converter._createBlock('operator_mathop', 'value'); + converter._addField(block, 'OPERATOR', 'floor'); if (converter._isString(receiver)) { - converter._addTextInput(block, 'NUM1', receiver, ''); + converter._addTextInput(block, 'NUM', receiver, ''); } else { - converter._addNumberInput(block, 'NUM1', 'math_number', receiver, ''); + converter._addNumberInput(block, 'NUM', 'math_number', receiver, ''); } - converter._addNumberInput(block, 'NUM2', 'math_number', 0, ''); block.comment = converter._createComment('@ruby:method:to_i', block.id); return block; }); diff --git a/packages/scratch-gui/test/unit/lib/dncl/dncl-to-ruby.test.js b/packages/scratch-gui/test/unit/lib/dncl/dncl-to-ruby.test.js index b7c9acec91f..9ce62c683c8 100644 --- a/packages/scratch-gui/test/unit/lib/dncl/dncl-to-ruby.test.js +++ b/packages/scratch-gui/test/unit/lib/dncl/dncl-to-ruby.test.js @@ -38,8 +38,15 @@ describe('dnclToRuby', () => { }) describe('operators', () => { + // Both `÷` and `/` are integer division in DNCL (and `//` is the + // explicit alias). All three produce `(... / ...).to_i` on the Ruby + // side; the runtime truncates via the floor mathop block. test('division with ÷', () => { - expect(convert('a = 10 ÷ 3')).toBe('@a = 10 / 3') + expect(convert('a = 10 ÷ 3')).toBe('@a = (10 / 3).to_i') + }) + + test('division with /', () => { + expect(convert('a = 10 / 3')).toBe('@a = (10 / 3).to_i') }) test('integer division with //', () => { diff --git a/packages/scratch-gui/test/unit/lib/dncl/dncl-v2-example.test.js b/packages/scratch-gui/test/unit/lib/dncl/dncl-v2-example.test.js index 94ace0a8a75..ffe7c28cc68 100644 --- a/packages/scratch-gui/test/unit/lib/dncl/dncl-v2-example.test.js +++ b/packages/scratch-gui/test/unit/lib/dncl/dncl-v2-example.test.js @@ -89,7 +89,7 @@ describe('DNCLv2 end-to-end: linear search example (Issue #640)', () => { '@migi = @kazu - 1', '@owari = 0', 'while @hidari <= @migi && @owari == 0', - ' @aida = (@hidari+@migi) / 2 # 演算子÷は商の整数値を返す', + ' @aida = ((@hidari+@migi) / 2).to_i # 演算子÷は商の整数値を返す', ' if @_array_Data_[@aida] == @atai', ' puts(@atai.to_s + "は" + @aida.to_s + "番目にありました")', ' @owari = 1', diff --git a/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/operators/type-conversion.test.js b/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/operators/type-conversion.test.js index e59fd6dec43..1950f38fb39 100644 --- a/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/operators/type-conversion.test.js +++ b/packages/scratch-gui/test/unit/lib/ruby-to-blocks-converter/operators/type-conversion.test.js @@ -69,20 +69,20 @@ describe('RubyToBlocksConverter/Operators', () => { }); test('to_i', async () => { + // `.to_i` now maps to operator_mathop(floor) so the runtime actually + // truncates. The `@ruby:method:to_i` marker preserves the source + // method name on round-trip; legacy operator_add(x, 0) projects with + // the same marker still emit `.to_i` via the generator's compat path. code = 'x.to_i'; expected = [ { - opcode: 'operator_add', + opcode: 'operator_mathop', + fields: [{name: 'OPERATOR', value: 'floor'}], inputs: [ { - name: 'NUM1', + name: 'NUM', block: (await rubyToExpected(converter, target, 'x'))[0], shadow: expectedInfo.makeNumber('') - }, - { - name: 'NUM2', - block: expectedInfo.makeNumber(0), - shadow: expectedInfo.makeNumber(0) } ], comment: { @@ -96,16 +96,12 @@ describe('RubyToBlocksConverter/Operators', () => { code = '"123".to_i'; expected = [ { - opcode: 'operator_add', + opcode: 'operator_mathop', + fields: [{name: 'OPERATOR', value: 'floor'}], inputs: [ { - name: 'NUM1', + name: 'NUM', block: expectedInfo.makeText('123') - }, - { - name: 'NUM2', - block: expectedInfo.makeNumber(0), - shadow: expectedInfo.makeNumber(0) } ], comment: {