UNPKG

nadesiko3

Version:
1,280 lines 68.7 kB
// @ts-nocheck /* eslint-disable no-template-curly-in-string */ /** なでしこのtokenのtypeをscope(CSSのクラス名)に変換する。 */ import { OffsetToLineColumn } from '../core/src/nako_source_mapping.mjs'; import { NakoError } from '../core/src/nako_errors.mjs'; import NakoIndent from '../core/src/nako_indent.mjs'; import { NakoPrepare } from '../core/src/nako_prepare.mjs'; // alias const getBlockStructure = NakoIndent.getBlockStructure; const getIndent = NakoIndent.getIndent; const countIndent = NakoIndent.countIndent; const isIndentSyntaxEnabled = NakoIndent.isIndentSyntaxEnabled; /** * シンタックスハイライトでは一般にテキストの各部分に 'comment.line' のようなラベルを付け、各エディタテーマがそのそれぞれの色を設定する。 * ace editor では例えば 'comment.line' が付いた部分はクラス .ace_comment.ace_line が付いたHTMLタグで囲まれ、各テーマはそれに対応するCSSを実装する。 * @returns TokenType */ export function getScope(token) { switch (token.type) { case 'line_comment': return 'comment.line'; case 'range_comment': return 'comment.block'; case 'def_test': return 'keyword.control'; case 'def_func': return 'keyword.control'; case 'func': return 'entity.name.function'; case 'number': return 'constant.numeric'; // 独立した助詞 case 'とは': case 'ならば': case 'でなければ': return 'keyword.control'; // 制御構文 case 'ここから': case 'ここまで': case 'もし': case '違えば': case 'require': return 'keyword.control'; // 予約語 case '回': case '間': case '繰り返す': case '反復': case '抜ける': case '続ける': case '戻る': case '先に': case '次に': case '代入': case '逐次実行': case '条件分岐': case '取込': case 'エラー監視': case 'エラー': case '変数': case '実行速度優先': return 'keyword.control'; case '定める': case '定数': return 'support.constant'; // 演算子 case 'shift_r0': case 'shift_r': case 'shift_l': case 'gteq': case 'lteq': case 'noteq': case 'eq': case 'not': case 'gt': case 'lt': case 'and': case 'or': case '@': case '+': case '-': case '**': case '*': case '÷÷': case '/': case '%': case '^': case '&': return 'keyword.operator'; case 'string': case 'string_ex': return 'string.other'; case 'word': if (['そう', 'それ', '回数', '対象キー', '対象'].includes(token.value)) { return 'variable.language'; } else { return 'variable.other'; } default: return 'markup.other'; } } /** * @param {TokenWithSourceMap} compilerToken * @param {NakoCompiler} nako3 * @param {string} value * @param {boolean} includesLastCharacter * @param {boolean} underlineJosi */ export function getEditorTokens(compilerToken, nako3, value, includesLastCharacter, underlineJosi) { const type = getScope(compilerToken); const docHTML = getDocumentationHTML(compilerToken, nako3); // 助詞があれば助詞の部分を分割する。 // 最後の文字が現在の行に含まれないときは助詞を表示しない。そうしないと例えば `「文字列\n」を表示` の「列」の部分に下線が引かれてしまう。 if (compilerToken.rawJosi && value.length >= compilerToken.rawJosi.length && includesLastCharacter && underlineJosi) { return [ { type, docHTML, value: value.slice(0, -compilerToken.rawJosi.length) }, { type: type + '.markup.underline', docHTML, value: value.slice(-compilerToken.josi.length) } ]; } return [ { type, docHTML, value } ]; } /** * `name` が定義されたプラグインの名前を返す。 */ export function findPluginName(name, nako3) { for (const pluginName of Object.keys(nako3.__module)) { if (Object.keys(nako3.__module[pluginName]).includes(name)) { return pluginName; } } return null; } /** * i = 0, 1, 2, ... に対して 'A', 'B', 'C', ... 'Z', 'AA', 'AB', ... を返す。 */ export function createParameterName(i) { const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''); return i.toString(26).split('').map((v) => alphabet[parseInt(v, 26)]).join(''); } /** * パラメータの定義を表す文字列を生成する。例えば `[['と', 'の'], ['を']]` に対して `'(Aと|Aの、Bを)'` を返す、パラメータが無い場合、空文字列を返す。 */ export function createParameterDeclaration(josi) { const args = josi.map((union, i) => union.map((v) => `${createParameterName(i)}${v}`).join('|')).join('、'); if (args !== '') { return `(${args})`; } else { return ''; } } // https://stackoverflow.com/a/6234804 export function escapeHTML(t) { return t .replace(/&/g, '&amp;') .replace(/</g, '&lt;') .replace(/>/g, '&gt;') .replace(/"/g, '&quot;') .replace(/'/g, '&#039;'); } /** * 関数のドキュメントを返す。 */ export function getDocumentationHTML(token, nako3) { const meta = (text) => `<span class="tooltip-plugin-name">${escapeHTML(text)}</span>`; if (token.type === 'func') { const pluginName = findPluginName(token.value + '', nako3) || (token.meta && token.meta.file ? token.meta.file : null); const josi = (token.meta && token.meta.josi) ? createParameterDeclaration(token.meta.josi) : ''; // {関数} のとき token.meta.josi が存在しない if (pluginName) { return escapeHTML(josi + token.value) + meta(pluginName); } return escapeHTML(josi + token.value); } else if (token.type === 'word') { /** @type {string | null} */ const pluginName = findPluginName(token.value + '', nako3) || (token.meta && token.meta.file ? token.meta.file : null); if (pluginName) { return escapeHTML(token.value + '') + meta(pluginName); } } return null; } /** * ace editor ではエディタの文字列の全ての部分に何らかの `type` を付けなければならない。 * なでしこのエディタでは 'markup.other' をデフォルト値として使うことにした。 * @returns {EditorToken[]} */ const getDefaultTokens = (row, doc) => [{ type: 'markup.other', value: doc.getLine(row), docHTML: null }]; /** * 一時的にloggerを無効化する。そうしないとシンタックスハイライトの更新のたびにloggerへコンパイルエラーや警告が送られて、結果のボックスに行が追加されてしまう。 * @type {<T>(nako3: NakoCompiler, f: () => T) => T} */ const withoutLogger = (nako3, f) => { const logger = nako3.logger; try { nako3.replaceLogger(); return f(); } finally { nako3.logger = logger; } }; /** * プログラムをlexerでtokenizeした後、ace editor 用のトークン列に変換する。 * @param lines * @param nako3 * @param underlineJosi */ export function tokenize(lines, nako3, underlineJosi) { const code = lines.join('\n'); // 取り込み文を含めてしまうと依存ファイルが大きい時に時間がかかってしまうため、 // 取り込み文を無視してトークン化してから、依存ファイルで定義された関数名と一致するトークンを関数のトークンへ変換する。 nako3.reset({ needToClearPlugin: false }); const lexerOutput = withoutLogger(nako3, () => nako3.lex(code, 'main.nako3', undefined, true)); lexerOutput.commentTokens = lexerOutput.commentTokens.filter((t) => t.file === 'main.nako3'); lexerOutput.requireTokens = lexerOutput.requireTokens.filter((t) => t.file === 'main.nako3'); lexerOutput.tokens = lexerOutput.tokens.filter((t) => t.file === 'main.nako3'); // 外部ファイルで定義された関数名に一致するトークンのtypeをfuncに変更する。 // 取り込んでいないファイルも参照される問題や、関数名の重複がある場合に正しくない情報を表示する問題がある。 // eslint-disable-next-line no-lone-blocks { /** @type {Record<string, object>} */ for (const [file, { funclist }] of Object.entries(nako3.dependencies)) { for (const token of lexerOutput.tokens) { if (token.type === 'word' && token.value !== 'それ' && funclist[token.value]) { token.type = 'func'; // meta.file に定義元のファイル名を持たせる。 token.meta = { ...funclist[token.value + ''], file }; } } } } // eol、eof、長さが1未満のトークン、位置を特定できないトークンを消す /** @type {(TokenWithSourceMap & { startOffset: number, endOffset: number })[]} */ // @ts-ignore const tokens = [...lexerOutput.tokens, ...lexerOutput.commentTokens, ...lexerOutput.requireTokens].filter((t) => t.type !== 'eol' && t.type !== 'eof' && typeof t.startOffset === 'number' && typeof t.endOffset === 'number' && t.startOffset < t.endOffset); // startOffsetでソートする tokens.sort((a, b) => (a.startOffset || 0) - (b.startOffset || 0)); // 各行について、余る文字の無いようにエディタのトークンに変換する。 // 複数のトークンが重なることはないと仮定する。 let lineStartOffset = 0; let tokenIndex = 0; // 実際に必要なプロパティはtype, valueだけで、docは独自に追加した。 /** @type {EditorToken[][]} */ const editorTokens = []; // 各行のエディタのトークン for (let i = 0; i < lines.length; i++) { editorTokens.push([]); const lineEndOffset = lineStartOffset + lines[i].length; let offset = lineStartOffset; // 現在の行にかかっているトークンまで飛ばす while (tokenIndex < tokens.length && tokens[tokenIndex].endOffset <= lineStartOffset) { tokenIndex++; } // 行全体を完全にまたがっているトークンが存在する場合 if (tokenIndex < tokens.length && tokens[tokenIndex].startOffset <= lineStartOffset && tokens[tokenIndex].endOffset >= lineEndOffset) { editorTokens[i].push(...getEditorTokens(tokens[tokenIndex], nako3, lines[i], tokens[tokenIndex].endOffset <= lineEndOffset, underlineJosi)); } else { // 行頭をまたがっているトークンが存在する場合 if (tokenIndex < tokens.length && tokens[tokenIndex].startOffset <= lineStartOffset) { editorTokens[i].push(...getEditorTokens(tokens[tokenIndex], nako3, code.slice(offset, tokens[tokenIndex].endOffset), true, underlineJosi)); offset = tokens[tokenIndex].endOffset; tokenIndex++; } // 行頭も行末もまたがっていないトークンを処理する while (tokenIndex < tokens.length && tokens[tokenIndex].endOffset < lineEndOffset) { // このトークンと直前のトークンの間に隙間があるなら、埋める if (offset < tokens[tokenIndex].startOffset) { editorTokens[i].push({ type: 'markup.other', docHTML: null, value: code.slice(offset, tokens[tokenIndex].startOffset) }); offset = tokens[tokenIndex].startOffset; } // 現在のトークンを使う editorTokens[i].push(...getEditorTokens(tokens[tokenIndex], nako3, code.slice(offset, tokens[tokenIndex].endOffset), true, underlineJosi)); offset = tokens[tokenIndex].endOffset; tokenIndex++; } // 行末をまたがっているトークンが存在する場合 if (tokenIndex < tokens.length && tokens[tokenIndex].startOffset < lineEndOffset) { // トークンの前の隙間 if (offset < tokens[tokenIndex].startOffset) { editorTokens[i].push({ type: 'markup.other', docHTML: null, value: code.slice(offset, tokens[tokenIndex].startOffset) }); offset = tokens[tokenIndex].startOffset; } // トークンを使う editorTokens[i].push(...getEditorTokens(tokens[tokenIndex], nako3, code.slice(tokens[tokenIndex].startOffset, lineEndOffset), tokens[tokenIndex].endOffset <= lineEndOffset, underlineJosi)); } else { editorTokens[i].push({ type: 'markup.other', docHTML: null, value: code.slice(offset, lineEndOffset) }); } } lineStartOffset += lines[i].length + 1; } return { editorTokens, lexerOutput }; } /** * エディタ上にエラーメッセージの波線とgutterの赤いマークとエラーメッセージのポップアップを設定するためのクラス。 */ export class EditorMarkers { /** * @param {any} session * @param {AceDocument} doc * @param {TypeofAceRange} AceRange * @param {boolean} disable */ constructor(session, doc, AceRange, disable) { this.session = session; this.doc = doc; this.AceRange = AceRange; /** @type {any[]} */ this.markers = []; this.hasAnnotations = false; this.disable = disable; } /** * @param {number} startLine * @param {number | null} startColumn * @param {number | null} endLine * @param {number | null} endColumn * @param {(row: number) => string} getLine * @returns {[number, number, number, number]} */ static fromNullable(startLine, startColumn, endLine, endColumn, getLine) { if (startColumn === null) { startColumn = 0; } if (endLine === null) { endLine = startLine; } if (endColumn === null) { endColumn = getLine(endLine).length; } // 最低でも1文字分の長さをとる if (startLine === endLine && startColumn === endColumn) { endColumn++; } return [startLine, startColumn, endLine, endColumn]; } /** * @param {string} code @param {number} startOffset @param {number} endOffset * @returns {[number, number, number, number]} */ static fromOffset(code, startOffset, endOffset) { const offsetToLineColumn = new OffsetToLineColumn(code); const start = offsetToLineColumn.map(startOffset, false); const end = offsetToLineColumn.map(endOffset, false); return [start.line, start.column, end.line, end.column]; } /** * @param {string} code * @param {{ line?: number, startOffset?: number | null, endOffset?: number | null, message: string }} error * @param {(row: number) => string} getLine * @returns {[number, number, number, number]} */ static fromError(code, error, getLine) { if (typeof error.startOffset === 'number' && typeof error.endOffset === 'number') { // 完全な位置を取得できる場合 return this.fromOffset(code, error.startOffset, error.endOffset); } else if (typeof error.line === 'number') { // 行全体の場合 return this.fromNullable(error.line, null, null, null, getLine); } else { // 位置が不明な場合 return this.fromNullable(0, null, null, null, getLine); } } /** * @param {number} startLine * @param {number | null} startColumn * @param {number | null} endLine * @param {number | null} endColumn * @param {string} message * @param {'warn' | 'error'} type */ add(startLine, startColumn, endLine, endColumn, message, type) { if (this.disable) { return; } const range = new this.AceRange(...EditorMarkers.fromNullable(startLine, startColumn, endLine, endColumn, (row) => this.doc.getLine(row))); this.markers.push(this.session.addMarker(range, 'marker-' + (type === 'warn' ? 'yellow' : 'red'), 'text', false)); // typeは 'error' | 'warning' | 'info' this.session.setAnnotations([{ row: startLine, column: startColumn, text: message, type: type === 'warn' ? 'warning' : 'error' }]); this.hasAnnotations = true; } /** * @param {string} code * @param {{ line?: number, startOffset?: number | null, endOffset?: number | null, message: string }} error * @param {'warn' | 'error'} type */ addByError(code, error, type) { this.add(...EditorMarkers.fromError(code, error, (row) => this.doc.getLine(row)), error.message, type); } /** * 全てのエラーメッセージを削除する。 */ clear() { for (const marker of this.markers) { this.session.removeMarker(marker); } this.markers.length = 0; if (this.hasAnnotations) { this.session.clearAnnotations(); this.hasAnnotations = false; } } } /** * ace editor のBackgroundTokenizerを上書きして、シンタックスハイライトを自由に表示するためのクラス。 * ace editor ではシンタックスハイライトのために正規表現ベースのBackgroundTokenizerクラスを用意し定期的にトークン化を * 行っているが、正規表現ではなくなでしこのコンパイラの出力を使うためにはそれを上書きする必要がある。 */ export class BackgroundTokenizer { /** * @param {AceDocument} doc * @param {NakoCompiler} nako3 * @param {(firstRow: number, lastRow: number, ms: number) => void} onTokenUpdate * @param {(code: string, err: Error) => void} onCompileError * @param {boolean} underlineJosi */ constructor(doc, nako3, onTokenUpdate, onCompileError, underlineJosi) { this.onUpdate = onTokenUpdate; this.doc = doc; this.dirty = true; this.nako3 = nako3; this.onCompileError = onCompileError; this.underlineJosi = underlineJosi; // オートコンプリートで使うために、直近のtokenizeの結果を保存しておく /** @type {ReturnType<NakoCompiler['lex']> | null} */ this.lastLexerOutput = null; // 各行のパース結果。 // typeはscopeのこと。配列の全要素のvalueを結合した文字列がその行の文字列と等しくなる必要がある。 /** @type {EditorToken[][]} */ this.lines = this.doc.getAllLines().map((line) => [{ type: 'markup.other', value: line, docHTML: null }]); // this.lines は外部から勝手に編集されてしまうため、コピーを持つ /** @type {{ code: string, lines: string } | null} */ this.cache = null; this.deleted = false; /** @public */ this.enabled = true; const update = () => { if (this.deleted) { return; } if (this.dirty && this.enabled) { const startTime = Date.now(); this.dirty = false; const code = this.doc.getAllLines().join('\n'); try { const startTime = Date.now(); const out = tokenize(this.doc.getAllLines(), nako3, this.underlineJosi); this.lastLexerOutput = out.lexerOutput; this.lines = out.editorTokens; this.cache = { code, lines: JSON.stringify(this.lines) }; // ファイル全体の更新を通知する。 onTokenUpdate(0, this.doc.getLength() - 1, Date.now() - startTime); } catch (e) { onCompileError(code, e); } // tokenizeに時間がかかる場合、文字を入力できるように次回の実行を遅くする。 setTimeout(update, Math.max(100, Math.min(5000, (Date.now() - startTime) * 5))); } else { setTimeout(update, 100); } }; // コンストラクタが返る前にコールバックを呼ぶのはバグの元になるため一瞬待つ。 // たとえば `const a = new BackgroundTokenizer(..., () => { /* aを使った処理 */ }, ...)` がReferenceErrorになる。 setTimeout(() => { update(); }, 0); } dispose() { this.deleted = true; } /** * テキストに変更があったときに呼ばれる。IME入力中には呼ばれない。 * @param {{ action: string, start: { row: number, column: number }, end: { row: number, column: number }, lines: string[] }} delta */ $updateOnChange(delta) { this.dirty = true; const startRow = delta.start.row; const endRow = delta.end.row; if (startRow === endRow) { // 1行の編集 if (delta.action === 'insert' && this.lines[startRow]) { // 行内に文字列を挿入 const columnStart = delta.start.column; // updateOnChangeはIME入力中には呼ばれない。composition_placeholder を消さないとIME確定後の表示がずれる。 const oldTokens = this.lines[startRow] .filter((v) => v.type !== 'composition_placeholder'); /** @type {EditorToken[]} */ const newTokens = []; let i = 0; let offset = 0; // columnStartより左のトークンはそのまま保持する while (i < oldTokens.length && offset + oldTokens[i].value.length <= columnStart) { newTokens.push(oldTokens[i]); offset += oldTokens[i].value.length; i++; } // columnStartに重なっているトークンがあれば、2つに分割する if (i < oldTokens.length && offset < columnStart) { newTokens.push({ type: oldTokens[i].type, value: oldTokens[i].value.slice(0, columnStart - offset), docHTML: null }); newTokens.push({ type: 'markup.other', value: delta.lines[0], docHTML: null }); newTokens.push({ type: oldTokens[i].type, value: oldTokens[i].value.slice(columnStart - offset), docHTML: null }); i++; } else { newTokens.push({ type: 'markup.other', value: delta.lines[0], docHTML: null }); } // columnStartより右のトークンもそのまま保持する while (i < oldTokens.length) { newTokens.push(oldTokens[i]); i++; } this.lines[startRow] = newTokens; } else { this.lines[startRow] = getDefaultTokens(startRow, this.doc); } } else if (delta.action === 'remove') { // 範囲削除 this.lines.splice(startRow, endRow - startRow + 1, getDefaultTokens(startRow, this.doc)); } else { // 行の挿入 this.lines.splice(startRow, 1, ...Array(endRow - startRow + 1).fill(null).map((_, i) => getDefaultTokens(i + startRow, this.doc))); } } /** * tokenizerの出力を返す。文字入力したときに呼ばれる。 * @param {number} row */ getTokens(row) { // IME入力中はthis.lines[row]に自動的にnullが設定される。その場合新しく行のトークン列を生成して返さなければならない。 // 返した配列には自動的にIMEの入力用のテキストボックスであるcomposition_placeholderが挿入される。 if (!this.lines[row]) { let ok = false; if (this.enabled) { // tokenizeは非常に遅いため、キャッシュを使えるならそれを使う。 const code = this.doc.getAllLines().join('\n'); if (this.cache !== null && this.cache.code === code) { ok = true; } else { try { const lines = tokenize(this.doc.getAllLines(), this.nako3, this.underlineJosi); this.cache = { code, lines: JSON.stringify(lines.editorTokens) }; ok = true; } catch (e) { if (!(e instanceof NakoError)) { console.error(e); } } } } if (ok && this.cache !== null) { this.lines[row] = JSON.parse(this.cache.lines)[row]; } else { this.lines[row] = getDefaultTokens(row, this.doc); } } return this.lines[row]; } // ace側から呼ばれるが無視するメソッド // @ts-ignore start(startRow) { } // @ts-ignore fireUpdateEvent(firstRow, lastRow) { } // @ts-ignore setDocument(doc) { } scheduleStart() { } // @ts-ignore setTokenizer(tokenizer) { } stop() { } // @ts-ignore getState(row) { return 'start'; } } /** * シンタックスハイライト以外のエディタの挙動の定義。 */ export class LanguageFeatures { /** * @param {TypeofAceRange} AceRange * @param {NakoCompiler} nako3 */ constructor(AceRange, nako3) { this.AceRange = AceRange; this.nako3 = nako3; } /** * Ctrl + / の動作の定義。 * @param {string} state * @param {Session} session * @param {number} startRow * @param {number} endRow */ static toggleCommentLines(state, { doc }, startRow, endRow) { const prepare = NakoPrepare.getInstance(); /** * @param {string} line * @returns {{ type: 'blank' | 'code' } | { type: 'comment', start: number, len: number }} */ const parseLine = (line) => { // 先頭の空白を消す const indent = getIndent(line); if (indent === line) { return { type: 'blank' }; } line = line.substring(indent.length); // 先頭がコメントの開始文字かどうか確認する const ch2 = line.substring(0, 2).split('').map((c) => prepare.convert1ch(c)).join(''); if (ch2.substring(0, 1) === '#') { return { type: 'comment', start: indent.length, len: 1 + (line.charAt(1) === ' ' ? 1 : 0) }; } if (ch2 === '//') { return { type: 'comment', start: indent.length, len: 2 + (line.charAt(2) === ' ' ? 1 : 0) }; } return { type: 'code' }; }; /** @type {number[]} */ const rows = []; for (let i = startRow; i <= endRow; i++) { rows.push(i); } // 全ての行が空白行ならコメントアウト、全ての行が行コメントで始まるか空白行ならアンコメント、そうでなければコメントアウト。 if (!rows.every((row) => parseLine(doc.getLine(row)).type === 'blank') && rows.every((row) => parseLine(doc.getLine(row)).type !== 'code')) { // アンコメント for (const row of rows) { // 行コメントで始まる行ならアンコメントする。 // 行コメントの直後にスペースがあるなら、それも1文字だけ削除する。 const line = parseLine(doc.getLine(row)); if (line.type === 'comment') { doc.removeInLine(row, line.start, line.start + line.len); } } } else { // 最もインデントの低い行のインデント数を数える const minIndent = Math.min(...rows.map((row) => countIndent(doc.getLine(row)))); // コメントアウトする for (const row of rows) { const line = doc.getLine(row); let column = line.length; for (let i = 0; i < line.length; i++) { if (countIndent(line.slice(0, i)) >= minIndent) { column = i; break; } } doc.insertInLine({ row, column }, '// '); } } } /** * 文字を入力するたびに呼ばれる。trueを返すとautoOutdentが呼ばれる。 * @param {string} state * @param {string} line * @param {string} input * @returns {boolean} */ static checkOutdent(state, line, input) { // 特定のキーワードの入力が終わったタイミングでインデントを自動修正する。 // '違えば'のautoOutdentは「もし」と「条件分岐」のどちらのものか見分けが付かないため諦める。 // 「ここ|ま」(縦線がカーソル)の状態で「で」を打つとtrueになってしまう問題があるが、修正するには引数が足りない。 // eslint-disable-next-line no-irregular-whitespace return /^[  ・\t]*ここまで$/.test(line + input); } /** * checkOutdentがtrueを返したときに呼ばれる。 * @param {string} state * @param {Session} session * @param {number} row * @returns {void} */ autoOutdent(state, { doc }, row) { // 1行目なら何もしない if (row === 0) { return; } const prevLine = doc.getLine(row - 1); let indent; if (LanguageFeatures.isBlockStart(prevLine)) { // 1つ前の行が「〜ならば」などのブロック開始行なら、その行に合わせる。 indent = getIndent(prevLine); } else { // そうでなければ、1つ前の行のインデントから1段階outdentした位置に合わせる。 const s = this.getBlockStructure(doc.getAllLines().join('\n')); const parent = s.parents[row]; indent = parent !== null ? s.spaces[parent] : ''; } // 置換する const oldIndent = getIndent(doc.getLine(row)); doc.replace(new this.AceRange(row, 0, row, oldIndent.length), indent); } /** * エンターキーを押して行が追加されたときに挿入する文字列を指定する。 * @param {string} state * @param {string} line 改行前にカーソルがあった行の文字列 * @param {string} tab タブ文字(デフォルトでは " ") */ static getNextLineIndent(state, line, tab) { // ●で始まるか、特定のキーワードで終わる場合にマッチする。 if (this.isBlockStart(line)) { return getIndent(line) + tab; } return getIndent(line); } /** @param {string} line */ static isBlockStart(line) { // eslint-disable-next-line no-irregular-whitespace return /^[  ・\t]*●|(ならば|なければ|ここから|条件分岐|違えば|回|繰り返(す|し)|の間|反復|とは|には|エラー監視|エラーならば|実行速度優先)、?\s*$/.test(line); } /** * オートコンプリート * @param {number} row * @param {string} prefix getCompletionPrefixの出力 * @param {NakoCompiler} nako3 * @param {BackgroundTokenizer} backgroundTokenizer */ static getCompletionItems(row, prefix, nako3, backgroundTokenizer) { /** * keyはcaption。metaは候補の横に薄く表示されるテキスト。 * @type {Map<string, { value: string, meta: Set<string>, score: number }>} */ const result = new Map(); /** 引数のリストを含まない、関数名だけのリスト @type {Set<string>} */ const values = new Set(); /** * オートコンプリートの項目を追加する。すでに存在するならマージする。 * @param {string} caption @param {string} value @param {string} meta */ const addItem = (caption, value, meta) => { const item = result.get(caption); if (item) { item.meta.add(meta); } else { // 日本語の文字数は英語よりずっと多いため、ただ一致する文字数を数えるだけで十分。 const score = prefix.split('').filter((c) => value.includes(c)).length; result.set(caption, { value, meta: new Set([meta]), score }); values.add(value); } }; // プラグイン関数 for (const name of Object.keys(nako3.__varslist[0])) { if (name.startsWith('!')) { // 「!PluginBrowser:初期化」などを除外 continue; } const f = nako3.funclist[name]; if (typeof f !== 'object' || f === null) { continue; } const pluginName = findPluginName(name, nako3) || 'プラグイン'; if (f.type === 'func') { addItem(createParameterDeclaration(f.josi) + name, name, pluginName); } else { addItem(name, name, pluginName); } } // 依存ファイルが定義した関数名 for (const [file, { funclist }] of Object.entries(nako3.dependencies)) { for (const [name, f] of Object.entries(funclist)) { const josi = (f && f.type === 'func') ? createParameterDeclaration(f.josi) : ''; addItem(josi + name, name, file); } } // 現在のファイル内に存在する名前 if (backgroundTokenizer.lastLexerOutput !== null) { for (const token of backgroundTokenizer.lastLexerOutput.tokens) { const name = token.value + ''; // 同じ行のトークンの場合、自分自身にマッチしている可能性が高いため除外する。 // すでに定義されている場合も、定義ではなく参照の可能性が高いため除外する。 if (token.line === row || values.has(name)) { continue; } if (token.type === 'word') { addItem(name, name, '変数'); } else if (token.type === 'func') { const f = nako3.funclist[name]; const josi = (f && f.type === 'func') ? createParameterDeclaration(f.josi) : ''; addItem(josi + name, name, '関数'); } } } return Array.from(result.entries()).map(([caption, data]) => ({ caption, ...data, meta: Array.from(data.meta).join(', ') })); } /** * スニペット */ /** @param {string} text */ static getSnippets(text) { // インデント構文が有効化されているなら「ここまで」を消す const indentSyntax = isIndentSyntaxEnabled(text); /** @param {string} en @param {string} jp @param {string} snippet */ const item = (en, jp, snippet) => indentSyntax ? { caption: en, meta: `\u21E5 ${jp}`, score: 1, snippet: snippet.replace(/\t*ここまで(\n|$)/g, '').replace(/\t/g, ' ') } : { caption: en, meta: `\u21E5 ${jp}`, score: 1, snippet: snippet.replace(/\t/g, ' ') }; return [ item('if', 'もし〜ならば', 'もし${1:1=1}ならば\n\t${2:1を表示}\n違えば\n\t${3:2を表示}\nここまで\n'), item('times', '〜回', '${1:3}回\n\t${2:1を表示}\nここまで\n'), item('for', '繰り返す', '${1:N}で${2:1}から${3:3}まで繰り返す\n\t${4:Nを表示}\nここまで\n'), item('while', '〜の間', '${1:N<2の間}\n\tN=N+1\nここまで\n'), item('foreach', '〜を反復', '${1:[1,2,3]}を反復\n\t${2:対象を表示}\nここまで\n'), item('switch', '〜で条件分岐', '${1:N}で条件分岐\n\t${2:1}ならば\n\t\t${3:1を表示}\n\tここまで\n\t${4:2}ならば\n\t\t${5:2を表示}\n\tここまで\n\t違えば\n\t\t${6:3を表示}\n\tここまで\nここまで\n'), item('function', '●〜とは', '●(${1:AとBを})${2:足す}とは\n\t${3:A+Bを戻す}\nここまで\n'), item('test', '●テスト:〜とは', '●テスト:${2:足す}とは\n\t1と2を足す\n\tそれと3がASSERT等しい\nここまで\n'), item('try', 'エラー監視', 'エラー監視\n\t${1:1のエラー発生}\nエラーならば\n\t${2:2を表示}\nここまで\n') ]; } /** * @param {string} line * @param {NakoCompiler} nako3 */ static getCompletionPrefix(line, nako3) { /** @type {ReturnType<NakoCompiler['lex']>["tokens"] | null} */ let tokens = null; // ひらがなとアルファベットとカタカナと漢字のみオートコンプリートする。 if (line.length === 0 || !/[ぁ-んa-zA-Zァ-ヶー\u3005\u4E00-\u9FCF]/.test(line[line.length - 1])) { return ''; } // 現在の行のカーソルより前の部分をlexerにかける。速度を優先して1行だけ処理する。 try { nako3.reset(); tokens = withoutLogger(nako3, () => nako3.lex(line, 'completion.nako3', undefined, true)).tokens .filter((t) => t.type !== 'eol' && t.type !== 'eof'); } catch (e) { if (!(e instanceof NakoError)) { console.error(e); } } if (tokens === null || tokens.length === 0 || !tokens[tokens.length - 1].value) { return ''; } const prefix = tokens[tokens.length - 1].value + ''; // 単語の先頭がひらがなではなく末尾がひらがなのとき、助詞を打っている可能性が高いためオートコンプリートしない。 if (/[ぁ-ん]/.test(prefix[prefix.length - 1]) && !/[ぁ-ん]/.test(prefix[0])) { return ''; } // 最後のトークンの値を、オートコンプリートで既に入力した部分とする。 return prefix; } /** * 文字を打つたびに各行についてこの関数が呼ばれる。'start'を返した行はfold可能な範囲の先頭の行になる。 * @param {Session} session * @param {string} foldStyle * @param {number} row * @returns {'start' | ''} */ getFoldWidget({ doc }, foldStyle, row) { // 速度が重要なため正規表現でマッチする。 return LanguageFeatures.isBlockStart(doc.getLine(row)) ? 'start' : ''; } /** * getFoldWidgetが'start'を返した行に設置されるfold用のボタンが押されたときに呼ばれる。 * @param {Session} session * @param {string} foldStyle * @param {number} row * @returns {AceRange | null} foldする範囲 */ getFoldWidgetRange({ doc }, foldStyle, row) { const pair = this.getBlockStructure(doc.getAllLines().join('\n')).pairs.find((v) => v[0] === row); if (pair !== undefined) { return new this.AceRange(pair[0], doc.getLine(pair[0]).length, pair[1] - 1, doc.getLine(pair[1] - 1).length); } return null; } /** * @param {AceDocument} doc * @returns {CodeLens[]} */ static getCodeLens(doc) { const results = []; for (const [row, line] of Array.from(doc.getAllLines().entries())) { // eslint-disable-next-line no-irregular-whitespace const matches = /^[  ・\t]*●テスト:(.+?)(?:とは|$)/.exec(line); if (matches !== null) { results.push({ start: { row }, command: { title: 'テストを実行', id: 'runTest', arguments: [matches[1]] } }); } } return results; } /** * @param {string} code * @returns {ReturnType<getBlockStructure>} * @private */ getBlockStructure(code) { // キャッシュ if (!this.blockStructure || this.blockStructure.code !== code) { // @ts-ignore this.blockStructure = { code, data: getBlockStructure(code) }; } return this.blockStructure.data; } } /** * 複数ファイルを表示するための最低限のAPIを提供する。 * @typedef {{ content: string, cursor: { range: AceRange, reversed: boolean }, scroll: { top: number, left: number }, undoManger: any }} EditorTabState */ class EditorTabs { /** * @param {AceEditor} editor * @param {TypeofAceRange} AceRange * @param {any} UndoManager */ constructor(editor, AceRange, UndoManager) { this.editor = editor; this.AceRange = AceRange; this.UndoManager = UndoManager; } /** @param {string} content @returns {EditorTabState} */ newTab(content) { return { content, cursor: { range: new this.AceRange(0, 0, 0, 0), reversed: false }, scroll: { left: 0, top: 0 }, undoManger: new this.UndoManager() }; } /** @returns {EditorTabState} */ getTab() { return { content: this.editor.getValue(), cursor: { range: this.editor.session.selection.getRange(), reversed: this.editor.session.selection.isBackwards() }, scroll: { left: this.editor.session.getScrollLeft(), top: this.editor.session.getScrollTop() }, undoManger: this.editor.session.getUndoManager() }; } /** @param {EditorTabState} state */ setTab(state) { this.editor.setValue(state.content); this.editor.session.selection.setRange(state.cursor.range, state.cursor.reversed); this.editor.session.setScrollLeft(state.scroll.left); this.editor.session.setScrollTop(state.scroll.top); this.editor.session.setUndoManager(state.undoManger); } } class Options { /** Save Options */ static save(editor) { try { /** @type {any} */ const obj = {}; for (const key of ['syntaxHighlighting', 'keyboardHandler', 'theme', 'fontSize', 'wrap', 'useSoftTabs', 'tabSize', 'showInvisibles', 'enableLiveAutocompletion', 'indentedSoftWrap', 'underlineJosi']) { obj[key] = editor.getOption(key); } localStorage.setItem('nako3EditorOptions', JSON.stringify(obj)); } catch (e) { // JSON.stringify のエラー、localStorageのエラーなど console.error(e); return null; } } /** @param {AceEditor} editor */ static load(editor) { try { if (!window.localStorage) { return null; } const text = window.localStorage.getItem('nako3EditorOptions'); if (text === null) { return null; } const json = JSON.parse(text); if (['ace/keyboard/vscode', 'ace/keyboard/emacs', 'ace/keyboard/sublime', 'ace/keyboard/vim'].includes(json.keyboardHandler)) { editor.setOption('keyboardHandler', json.keyboardHandler); } if (['ace/theme/xcode', 'ace/theme/monokai'].includes(json.theme)) { editor.setOption('theme', json.theme); } if (typeof json.fontSize === 'number') { editor.setOption('fontSize', Math.min(48, Math.max(6, json.fontSize))); } for (const key of ['syntaxHighlighting', 'wrap', 'useSoftTabs', 'showInvisibles', 'enableLiveAutocompletion', 'indentedSoftWrap', 'underlineJosi']) { if (typeof json[key] === 'boolean') { editor.setOption(key, json[key]); } } if (typeof json.tabSize === 'number') { editor.setOption('tabSize', Math.min(16, Math.max(0, json.tabSize))); } } catch (e) { // JSONのパースエラー、localStorageのエラーなど console.error(e); return null; } } /** OptionPanelクラスをなでしこ用に書き換える。 */ static initPanel(OptionPanel, editor) { const panel = new OptionPanel(editor); // editorはエラーが飛ばなければ何でも良い // ページ内で一度だけ呼ぶ if (this.done) { return; } this.done = true; // renderメソッドを呼ぶとrenderOptionGroupにoptionGroups.Main、optionGroups.More が順に渡されることを利用して、optionGroupsを書き換える。 let isMain = true; panel.renderOptionGroup = (group) => { if (isMain) { // Main for (const key of Object.keys(group)) { delete group[key]; } // スマートフォンでも見れるように、文字数は最小限にする group['シンタックスハイライト'] = { path: 'syntaxHighlighting' }; group['キーバインド'] = { path: 'keyboardHandler', type: 'select', items: [ { caption: 'VSCode', value: 'ace/keyboard/vscode' }, { caption: 'Emacs', value: 'ace/keyboard/emacs' }, { caption: 'Sublime', value: 'ace/keyboard/sublime' }, { caption: 'Vim', value: 'ace/keyboard/vim' } ] }; group['カラーテーマ'] = { path: 'theme', type: 'select', items: [ { caption: 'ライト', value: 'ace/theme/xcode' }, { caption: 'ダーク', value: 'ace/theme/monokai' } ] }; group['文字サイズ'] = { path: 'fontSize', type: 'number', defaultValue: 16 }; group['行の折り返し'] = { path: 'wrap', type: 'select', items: [ { caption: 'なし', value: 'off' }, { caption: 'あり', value: 'free' } ] }; group['ソフトタブ'] = [{ path: 'useSoftTabs' }, { ariaLabel: 'Tab Size', path: 'tabSize', type: 'number', values: [2, 3, 4, 8, 16] }]; group['空白文字を表示'] = { path: 'showInvisibles' }; group['常に自動補完'] = { path: 'enableLiveAutocompletion' }; group['折り返した行をインデント'] = { path: 'indentedSoftWrap' }; group['助詞に下線を引く'] = { path: 'underlineJosi' }; isMain = false; } else { // More for (const key of Object.keys(group)) { delete group[key]; } } }; panel.render(); // 設定メニューは ace/ext/settings_menu.js の showSettingsMenu 関数によって開かれる。 // showSettingsMenu 関数は new OptionPanel(editor).render() で新しい設定パネルのインスタンスを生成するため、 // renderメソッドを上書きすることで、生成されたインスタンスにアクセスできる。 const render = OptionPanel.prototype.render; OptionPanel.prototype.render = function (...args) { render.apply(this, ...args); // 元の処理 // OptionPanel.setOption() で発火される setOption イベントをキャッチする this.on('setOption', () => { console.log('設定を保存しました。'); Options.save(this.editor); }); }; } } /** * ace/ext/language_tools の設定がグローバル変数で保持されているため、こちら側でもグローバル変数で管理しないと、エディタが複数あるときに正しく動かない。 * - captionはオートコンプリートの候補として表示されるテキスト * - metaはcaptionのテキストの右に薄く表示されるテキスト * - docHTMLはその更に右に独立したウィンドウで表示されるHTMLによる説明 * - valueは決定したときに実際に挿入される文字列。プレースホルダーを配置するなら代わりにsnippetに値を設定する。 * * @typedef {{ * getCompletions( * editor: any, * session: Session, * pos: { row: number, column: number }, * prefix: any, * callback: ( * a: null, * b: { meta: string, caption: string, value?: string, score: number, docHTML?: string, snippet?: string }[] * ) => void * ): void * getDocTooltip?(item: any): void * }} Completer * @type {Completer[]} */ const completers = []; let editorIdCounter = 0; /** * 指定したidのHTML要素をなでしこ言語のエディタにする。 * * - ace editor がグローバルに読み込まれている必要がある。 * - wnako3_editor.css を読み込む必要がある。 * - readonly にするには data-nako3-readonly="true" を設定する。 * - エラー位置の表示を無効化するには data-nako3-disable-marker="true" を設定する。 * - 縦方向にリサイズ可能にするには nako3-resizable="true" を設定する。 * - デバイスが遅いときにシンタックスハイライトを無効化する機能を切るには nako3-force-syntax-highlighting="true" を設定する。 * * @param {string | Element} idOrElement HTML要素 * @param {import('./wnako3')} nako3 * @param {any} ace */ export function setupEditor(idOrElement, nako3, ace) { /** @type {AceEditor} */ const editor = ace.edit(idOrElement); const element = typeof idOrElement === 'string' ? document.getElementById(idOrElement) : idOrElement; if (element === null) { throw new Error(`[wnako3_editor] idが ${idOrElement} のHTML要素は存在しません。`); } /** @type {TypeofAceRange} */ const AceRange = ace.require('ace/range').Range; const editorMarkers = new EditorMarkers(editor.session, editor.session.bgTokenizer.doc, AceRange, !!element.dataset.nako3DisableMarker); if (element.classList.contains('nako3_ace_mounted')) { // 同じエディタを誤って複数回初期化すると、ace editor の挙動を書き換えているせいで // 意図しない動作をしたため、すでにエディタとして使われていないことを確認する。 throw new Error('なでしこ言語のエディタの初期化処理を同一のHTML要素に対して複数回適用しました。'); } // lang="ja" があると表示がずれる問題の修正 #839 element.setAttribute('lang', 'en'); // 以前のバージョンではnako3_editorをhtmlに直接付けていたため、互換性のためnako3_editorとは別のクラス名を使用する。 element.classList.add('nako3_ace_mounted'); element.classList.add('nako3_editor'); // CSSのため const readonly = element.dataset.nako3Readonly; // eslint-disable-next-line no-extra-boolean-cast if (!!readonly) { element.classList.add('readonly'); editor.setReadOnly(true); } editor.setFontSize(16); /** @param {Session} session */ const resetEditorTokens = (session) => { // 一旦テキスト全体を消してから、元に戻す /** @type {AceDocument} */ const doc = session.doc; const lines = doc.getAllLines(); const range = session.selection.getRange(); doc.removeFullLines(0, doc.getLength()); doc.insert({ row: 0, column: 0 }, lines.join('\n')); session.selection.setRange(range, false); }; ace.require('ace/config').defineOptions(editor.constructor.prototype, 'editor', { syntaxHighlighting: { /** @type {(this: AceEditor, value: boolean) => void} */ set: function (value) { this.session.bgTokenizer.enabled = value; resetEditorTokens(this.session); }, initialValue: true }, underlineJosi: { /** @type {(this: AceEditor, value: boolean) => void} */ set: function (value) { this.session.bgTokenizer.underlineJosi = value; resetEditorTokens(this.session); }, initialValue: true } }); editor.setOptions({ wrap: 'free', indentedSoftWrap: false, showPrintMargin: false }); ace.require('ace/keybindings/vscode'); editor.setKeyboardHandler('ace/keyboard/vscode'); // ドキュメントのホバー const Tooltip = ace.require('ace/tooltip').Tooltip; const tooltip = new Tooltip(editor.container); const event = ace.require('ace/lib/event'); event.addListener(editor.renderer.content, 'mouseout', () => { // マウスカーソルがエディタの外に出たら、tooltipを隠す tooltip.hide(); }); editor.on('mousemove', (e) => { // マウスカーソルがトークンに重なったときにtooltipを表示する。モバイル端末の場合はトークンにカーソルが当たったときに表示される。 const pos = e.getDocumentPosition(); // getTokenAtはcolumnが行末より大きいとき行末のトークンを返してしまう。 if (pos.column >= e.editor.session.getLine(pos.row).length) { tooltip.hide(); return; } // getTokenAtは実際よりも1文字右のトークンを取得してしまうため、columnに1を足している。 /** @type {EditorToken} */ const token = e.editor.session.getTokenAt(pos.row, pos.column + 1); if (token === null || !token.docHTML) { // ドキュメントが存在しないトークンならtooltipを表示しない tooltip.hide(); return; } tooltip.setHtml(token.docHTML); tooltip.show(null, e.clientX, e.clientY); }); editor.session.on('change', () => { // モバイル端末でドキュメントが存在するトークンを編集するときにツールチップが消えない問題を解消するために、文字を打ったらtooltipを隠す。 tooltip.hide(); // 文字入力したらマーカーを消す editorMarkers.clear(); }); editor.on('guttermousedown', (e) => { const target = e.domEvent.target; if (target.className.indexOf('ace_gutter-cell') === -1) { return; } if (!editor.isFocused()) { return; } const row = e.getDocumentPosition().row; const editorId = e.editor.editorId || 0; if (target.className.indexOf('ace_breakpoint') === -1) { e.editor.session.setBreakpoint(row); window.postMessage({ action: 'breakpoint:on', row, editorId }); } else { e.editor.session.clearBreakpoint(row); window.postMessage({ action: 'breakpoint:off', row, editorId }); } // console.log('BREAKPOINT=', row, 'editorId=', editorId) e.stop(); }); const forceSyntaxHighlighting = !!element.dataset.nako3ForceSyntaxHighlighting; let isFirstTime = true; const oldBgTokenizer = editor.session.bgTokenizer; const backgroundTokenizer = new BackgroundTokenizer(editor.session.bgTokenizer.doc, nako3, (firstRow, lastRow, ms) => { oldBgTokenizer._signal('update', { data: { first: firstRow, last: lastRow } }); // 処理が遅い場合シンタックスハイライトを無効化する。 if (ms > 220 && editor.getOption('syntaxHighlighting') && !readonly && !forceSyntaxHighlighting && isFirstTime) { isFirstTime = false; slowSpeedMessage.classList.add('visible'); editor.setOption('syntaxHighlighting', false); setTimeout(() => { slowSpeedMessage.classList.remove('visible'); }, 13000); } }, (code, err) => { editorMarkers.addByError(code, err, 'error'); }, /** @type {boolean} */ (editor.getOption('underlineJosi'))); // オートコンプリートを有効化