|
| 1 | +// === Smalruby: This file is Smalruby-specific (class insertion utility for Ruby tab) === |
| 2 | + |
| 3 | +const INDENT = ' '; |
| 4 | + |
| 5 | +/** |
| 6 | + * Quote a string for Ruby output (double-quoted). |
| 7 | + * @param {string} string The string to quote. |
| 8 | + * @returns {string} The quoted string. |
| 9 | + */ |
| 10 | +const quote = string => { |
| 11 | + const escapeChars = { |
| 12 | + '\\': '\\\\', |
| 13 | + '"': '\\"', |
| 14 | + '\n': '\\n', |
| 15 | + '\t': '\\t' |
| 16 | + }; |
| 17 | + const s = String(string); |
| 18 | + const sb = ['"']; |
| 19 | + for (let i = 0; i < s.length; i++) { |
| 20 | + const ch = s.charAt(i); |
| 21 | + sb.push(escapeChars[ch] || ch); |
| 22 | + } |
| 23 | + sb.push('"'); |
| 24 | + return sb.join(''); |
| 25 | +}; |
| 26 | + |
| 27 | +/** |
| 28 | + * Prepend a prefix onto each line of code. |
| 29 | + * @param {string} text The lines of code. |
| 30 | + * @param {string} prefix The common prefix. |
| 31 | + * @returns {string} The prefixed lines of code. |
| 32 | + */ |
| 33 | +const prefixLines = (text, prefix = INDENT) => |
| 34 | + prefix + text.replace(/(?!\n$)\n/g, `\n${prefix}`); |
| 35 | + |
| 36 | +/** |
| 37 | + * Check if a string is a valid Ruby constant name (class name). |
| 38 | + * @param {string} name The name to check. |
| 39 | + * @returns {boolean} Whether the name is a valid class name. |
| 40 | + */ |
| 41 | +const isValidClassName = name => /^[A-Z][\p{L}\p{N}_]*$/u.test(name); |
| 42 | + |
| 43 | +/** |
| 44 | + * Check if code already contains a class definition. |
| 45 | + * Matches lines starting with optional whitespace followed by "class ". |
| 46 | + * Ignores comments (lines starting with #). |
| 47 | + * @param {string} code The code to check. |
| 48 | + * @returns {boolean} Whether the code contains a class definition. |
| 49 | + */ |
| 50 | +const hasClassDefinition = code => { |
| 51 | + const lines = code.split('\n'); |
| 52 | + for (const line of lines) { |
| 53 | + const trimmed = line.trimStart(); |
| 54 | + if (trimmed.startsWith('#')) continue; |
| 55 | + if (/^class\s/.test(trimmed)) return true; |
| 56 | + } |
| 57 | + return false; |
| 58 | +}; |
| 59 | + |
| 60 | +/** |
| 61 | + * Generate set_xxx lines for sprite attributes that differ from defaults. |
| 62 | + * @param {object} target The sprite target. |
| 63 | + * @param {string[]} setLines Array to push set lines into. |
| 64 | + */ |
| 65 | +const generateSetXxx = (target, setLines) => { |
| 66 | + if (target.x !== 0) { |
| 67 | + setLines.push(`set_x ${target.x}`); |
| 68 | + } |
| 69 | + if (target.y !== 0) { |
| 70 | + setLines.push(`set_y ${target.y}`); |
| 71 | + } |
| 72 | + if (target.direction !== 90) { |
| 73 | + setLines.push(`set_direction ${target.direction}`); |
| 74 | + } |
| 75 | + if (!target.visible) { |
| 76 | + setLines.push(`set_visible ${!!target.visible}`); |
| 77 | + } |
| 78 | + if (target.size !== 100) { |
| 79 | + setLines.push(`set_size ${target.size}`); |
| 80 | + } |
| 81 | + if (target.currentCostume > 0) { |
| 82 | + setLines.push(`set_current_costume ${target.currentCostume + 1}`); |
| 83 | + } |
| 84 | + if (target.rotationStyle !== 'all around') { |
| 85 | + setLines.push(`set_rotation_style ${quote(target.rotationStyle)}`); |
| 86 | + } |
| 87 | +}; |
| 88 | + |
| 89 | +/** |
| 90 | + * Generate set_xxx lines for stage attributes that differ from defaults. |
| 91 | + * @param {object} target The stage target. |
| 92 | + * @param {string[]} setLines Array to push set lines into. |
| 93 | + */ |
| 94 | +const generateStageSetXxx = (target, setLines) => { |
| 95 | + if (target.currentCostume > 0) { |
| 96 | + setLines.push(`set_current_backdrop ${target.currentCostume + 1}`); |
| 97 | + } |
| 98 | +}; |
| 99 | + |
| 100 | +/** |
| 101 | + * Wrap existing code with a class definition. |
| 102 | + * Returns null if code already contains a class definition. |
| 103 | + * This replicates the logic of RubyGenerator._wrapWithClass with forFileOutput=true. |
| 104 | + * @param {string} code The current editor code. |
| 105 | + * @param {object} target The editing target (vm.editingTarget). |
| 106 | + * @returns {string|null} The wrapped code, or null if class already exists. |
| 107 | + */ |
| 108 | +const wrapCurrentCodeWithClass = (code, target) => { |
| 109 | + if (hasClassDefinition(code)) { |
| 110 | + return null; |
| 111 | + } |
| 112 | + |
| 113 | + const isStage = target.isStage; |
| 114 | + let className; |
| 115 | + const setLines = []; |
| 116 | + |
| 117 | + if (isStage) { |
| 118 | + className = 'Stage'; |
| 119 | + if (target.sprite.name !== 'Stage') { |
| 120 | + setLines.push(`set_name ${quote(target.sprite.name)}`); |
| 121 | + } |
| 122 | + } else { |
| 123 | + const spriteName = target.sprite.name; |
| 124 | + if (isValidClassName(spriteName)) { |
| 125 | + className = spriteName; |
| 126 | + } else { |
| 127 | + const sprites = target.runtime.targets.filter(t => !t.isStage); |
| 128 | + const index = sprites.indexOf(target) + 1; |
| 129 | + className = `Sprite${index}`; |
| 130 | + setLines.push(`set_name ${quote(spriteName)}`); |
| 131 | + } |
| 132 | + } |
| 133 | + |
| 134 | + // Generate set_xxx for non-default attributes |
| 135 | + if (isStage) { |
| 136 | + generateStageSetXxx(target, setLines); |
| 137 | + } else { |
| 138 | + generateSetXxx(target, setLines); |
| 139 | + } |
| 140 | + |
| 141 | + let setCode = ''; |
| 142 | + if (setLines.length > 0) { |
| 143 | + setCode = setLines.map(line => `${INDENT}${line}\n`).join(''); |
| 144 | + } |
| 145 | + |
| 146 | + // Split code into hat/def blocks vs other top-level code |
| 147 | + let bodyCode = code; |
| 148 | + let outsideCode = ''; |
| 149 | + |
| 150 | + if (bodyCode.length > 0) { |
| 151 | + const sections = bodyCode.split(/\n\n/); |
| 152 | + const insideSections = []; |
| 153 | + const outsideSections = []; |
| 154 | + for (const section of sections) { |
| 155 | + const trimmed = section.trim(); |
| 156 | + if (trimmed.length === 0) continue; |
| 157 | + if (/^self\.when\(/.test(trimmed) || |
| 158 | + /^when_/.test(trimmed) || |
| 159 | + /^\w+\.when[\s_(]/.test(trimmed) || |
| 160 | + /^def /.test(trimmed)) { |
| 161 | + insideSections.push(section); |
| 162 | + } else { |
| 163 | + outsideSections.push(section); |
| 164 | + } |
| 165 | + } |
| 166 | + bodyCode = insideSections.join('\n\n'); |
| 167 | + if (bodyCode.length > 0 && !bodyCode.endsWith('\n')) { |
| 168 | + bodyCode += '\n'; |
| 169 | + } |
| 170 | + if (outsideSections.length > 0) { |
| 171 | + const commented = outsideSections |
| 172 | + .join('\n\n') |
| 173 | + .split('\n') |
| 174 | + .map(line => (line.trim().length > 0 ? `# ${line}` : '')) |
| 175 | + .join('\n'); |
| 176 | + outsideCode = `\n${commented}\n`; |
| 177 | + } |
| 178 | + } |
| 179 | + |
| 180 | + if (bodyCode.length > 0) { |
| 181 | + bodyCode = prefixLines(bodyCode, INDENT); |
| 182 | + } |
| 183 | + |
| 184 | + const separator = setCode.length > 0 && bodyCode.length > 0 ? '\n' : ''; |
| 185 | + const inheritance = isStage ? '' : ' < ::Smalruby3::Sprite'; |
| 186 | + let result = `class ${className}${inheritance}\n${setCode}${separator}${bodyCode}end\n`; |
| 187 | + |
| 188 | + if (outsideCode.length > 0) { |
| 189 | + result += outsideCode; |
| 190 | + } |
| 191 | + |
| 192 | + return result; |
| 193 | +}; |
| 194 | + |
| 195 | +export {wrapCurrentCodeWithClass, hasClassDefinition}; |
0 commit comments