Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion packages/scratch-gui/src/lib/dncl/dncl-identifier-converter.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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, '>=')
Expand All @@ -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
}

Expand Down
269 changes: 268 additions & 1 deletion packages/scratch-gui/src/lib/dncl/paren-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
7 changes: 7 additions & 0 deletions packages/scratch-gui/src/lib/dncl/ruby-to-dncl-builtins.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
replaceCall,
skipString,
splitArgsAtTopLevel,
stripIntegerDivisionToI,
} from './paren-utils'
import { ID } from './ruby-to-dncl-identifier'

Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});
Expand Down
9 changes: 8 additions & 1 deletion packages/scratch-gui/test/unit/lib/dncl/dncl-to-ruby.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 //', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading
Loading