diff --git a/.gitignore b/.gitignore index dba8a59..1d3ea8a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ node_modules temp +*.vsix +etc/server_response.txt diff --git a/package-lock.json b/package-lock.json index 1cc7910..ac01181 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { - "name": "dandy-vscode", - "version": "0.1.2", + "name": "vscode-dandy", + "version": "0.1.3", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -628,10 +628,21 @@ } }, "eslint-utils": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-1.3.1.tgz", - "integrity": "sha512-Z7YjnIldX+2XMcjr7ZkgEsOj/bREONV60qYeB/bjMAqqqZ4zxKyWX+BOUkdmRmA9riiIPVvo5x86m5elviOk0Q==", - "dev": true + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-1.4.3.tgz", + "integrity": "sha512-fbBN5W2xdY45KulGXmLHZ3c3FHfVYmKg0IrAKGOkT/464PQsx2UeIzfz1RmEci+KLm1bBaAzZAh8+/E+XAeZ8Q==", + "dev": true, + "requires": { + "eslint-visitor-keys": "^1.1.0" + }, + "dependencies": { + "eslint-visitor-keys": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.1.0.tgz", + "integrity": "sha512-8y9YjtM1JBJU/A9Kc+SbaOV4y29sSWckBwMHa+FGtVj5gN/sbnKDf6xJUl+8g7FAij9LVaP8C24DUiH/f/2Z9A==", + "dev": true + } + } }, "eslint-visitor-keys": { "version": "1.0.0", diff --git a/package.json b/package.json index 610d774..1d3e4d8 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,10 @@ "activationEvents": [ "onCommand:dandy.run" ], - "categories": ["Linters", "Other"], + "categories": [ + "Linters", + "Other" + ], "contributes": { "commands": [ { @@ -15,7 +18,20 @@ "command": "dandy.fixAll", "title": "모두 고치기" } - ] + ], + "configuration": { + "title": "Dandy", + "properties": { + "dandy.exceptWords": { + "description": "맞춤법 검사 결과에서 제외할 어휘", + "scope": "resource", + "type": "array", + "items": { + "type": "string" + } + } + } + } }, "dependencies": { "request": "^2.88.0" @@ -28,7 +44,11 @@ "vscode": "^1.33.1" }, "icon": "img/icon.png", - "keywords": ["hangul", "korean", "spelling"], + "keywords": [ + "hangul", + "korean", + "spelling" + ], "main": "src/extension.js", "name": "vscode-dandy", "publisher": "fallroot", diff --git a/src/code-action-provider.js b/src/code-action-provider.js index 80feed4..cb925c9 100644 --- a/src/code-action-provider.js +++ b/src/code-action-provider.js @@ -5,33 +5,21 @@ function getProvider (document, range, context, token) { context.diagnostics.forEach(diagnostic => { diagnostic.answers.forEach(message => { - codeActions.push(generateForFix({ document, message, range: diagnostic.range })) + codeActions.push(makeQuickFixCommand('"'+message+'"', [{document, message, range: diagnostic.range}], "dandy.fix")) }) - codeActions.push(generateForSkip({ document, diagnostic })) + codeActions.push(makeQuickFixCommand("건너뛰기",[diagnostic],"dandy.skip" )) + codeActions.push(makeQuickFixCommand("예외추가",[document.getText(range)],"dandy.addToException")) }) return codeActions } -function generateForFix ({ document, message, range }) { - const codeAction = new vscode.CodeAction(message, vscode.CodeActionKind.QuickFix) - +function makeQuickFixCommand(menuTitle, commandArgs, commandName) { + const codeAction = new vscode.CodeAction(menuTitle, vscode.CodeActionKind.QuickFix) codeAction.command = { - arguments: [{ document, message, range }], - command: 'dandy.fix' + arguments: commandArgs, + command: commandName } - - return codeAction -} - -function generateForSkip ({ document, diagnostic }) { - const codeAction = new vscode.CodeAction('건너뛰기', vscode.CodeActionKind.QuickFix) - - codeAction.command = { - arguments: [diagnostic], - command: 'dandy.skip' - } - return codeAction } diff --git a/src/extension.js b/src/extension.js index 596d8e7..445b1ec 100644 --- a/src/extension.js +++ b/src/extension.js @@ -2,100 +2,148 @@ const vscode = require('vscode') const codeActionProvider = require('./code-action-provider') const spellChecker = require('./spell-checker') +// collection is a per-extension Map const collection = vscode.languages.createDiagnosticCollection('dandy') -const resultMap = new WeakMap() +// const resultMap = new WeakMap() function activate (context) { const subs = context.subscriptions subs.push(vscode.commands.registerTextEditorCommand('dandy.run', run)) subs.push(vscode.commands.registerCommand('dandy.fix', fix)) - subs.push(vscode.commands.registerCommand('dandy.fixAll', fixAll)) subs.push(vscode.commands.registerCommand('dandy.skip', skip)) + subs.push(vscode.commands.registerCommand('dandy.addToException', addToException)) subs.push(vscode.languages.registerCodeActionsProvider(['markdown', 'plaintext'], codeActionProvider)) subs.push(vscode.workspace.onDidChangeTextDocument(onDidChangeTextDocument)) + subs.push(vscode.workspace.onDidCloseTextDocument(onDidCloseTextDocument)) + subs.push(vscode.workspace.onDidSaveTextDocument(onDidSaveTextDocument)) subs.push(collection) } -function run () { +function run() { + const editor = getEditor() if (!editor) return - const document = editor.document - const selection = editor.selection - const empty = selection.isEmpty - const text = document.getText(empty ? undefined : selection) - - vscode.window.withProgress({ - location: vscode.ProgressLocation.Notification, - title: '맞춤법 검사를 진행하고 있습니다.' - }, () => { - return spellChecker.execute(text).then(result => { - resultMap.set(document, result.errors) - setCollections(document) + const document = editor.document; + const selection = editor.selection; + const empty = selection.isEmpty; + // 맞춤법 서버로 전송한 텍스트가 Windows 포맷, 즉 CR LF로 줄바꿈되어 있더라도 + // 결과의 오프셋 값은 CR 만의 줄바꿈 기준으로 되어 있다. 이 때문에 오차가 생기는 + // 것을 방지하기 위해 LF를 공백으로 대체하여 보냄 + const text = document.getText(empty ? undefined : selection).replace(/\n/g, ' '); + const startOffset = empty ? 0 : document.offsetAt(selection.start); + + vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: '맞춤법 검사를 진행하고 있습니다.' + }, + async (progress) => { + return new Promise(async (resolve, reject) => { + try { + const texts = splitter(text, 8000); + const errors = []; + var splitStart = startOffset; + for (const t of texts) { + + const result = await spellChecker.execute(t); + + for (error of result.errors) { + error.start += splitStart; + error.end += splitStart; + errors.push(error); + } + splitStart += t.length; + } + setCollections(document, errors); + resolve(); + } catch (error) { + vscode.window.showInformationMessage(error); + reject(error); + } }) - }) + } + ); + +} + +// limit 길이 한도 내에서 문장 단위로 split +function splitter(str, limit) { + const sentences = str.match( /[^\.!\?]+[\.!\?]+/g ); + const splits = []; + var partial = ""; + sentences.forEach(s=> { + if (partial.length+s.length>limit) { + splits.push(partial); + partial=s; + } else { + partial+=s; + } + }); + if (partial.length>1) + splits.push(partial); + return splits; } function fix ({ document, message, range }) { let edit = new vscode.WorkspaceEdit() edit.replace(document.uri, range, message) vscode.workspace.applyEdit(edit) -} - -function fixAll () { - const document = getDocument() - const uri = document.uri - const diagnostics = collection.get(uri) - const edit = new vscode.WorkspaceEdit() - - diagnostics.forEach(diagnostic => edit.replace(uri, diagnostic.range, diagnostic.answers[0])) - vscode.workspace.applyEdit(edit).then(() => collection.clear()).catch(console.error) + // 해당 diagnostic은 onDidChangeTextDocument 에서 삭제됨 } function skip (diagnostic) { - const document = getDocument() - const uri = document.uri - let diagnostics = collection.get(uri).slice() + const uri = getDocument().uri; + const diagnostics = collection.get(uri).slice() const index = diagnostics.indexOf(diagnostic) - if (index < 0) return - - const errors = resultMap.get(document) - - resultMap.set(document, errors.splice(errors.indexOf(diagnostic.error), 1)) diagnostics.splice(index, 1) collection.set(uri, diagnostics) } -function setCollections (document, errors) { - const text = document.getText() - const diagnostics = [] - - if (errors === undefined) { - errors = resultMap.get(document) +async function addToException(word) { + // 예외처리할 word를 workspace별 dictionary에 저장 + const config = vscode.workspace.getConfiguration("dandy") + let words = config.get('exceptWords'); + if (!words) words = [] + if (!words.includes(word)) { + words.push(word); + words.sort(); } + // Diagnostic에서 같은 단어 삭제 + const doc = getDocument(); + const diags = collection.get(doc.uri); + const newDiags = diags.filter(diag => doc.getText(diag.range)!=word); + collection.set(doc.uri, newDiags); + // workspace .vscode/settings.json에 저장 + await config.update('exceptWords', words, vscode.ConfigurationTarget.workspace); +} - errors.forEach(error => { - const keyword = error.before - let index = text.indexOf(keyword) - - while (index >= 0) { - const start = document.positionAt(index) - const end = document.positionAt(index + keyword.length) - const range = new vscode.Range(start, end) +function setCollections(document, errors) { + const text = document.getText() + const diagnostics = [] + const config = vscode.workspace.getConfiguration("dandy") + let exceptWords = config.get('exceptWords'); + if (!exceptWords) exceptWords = [] + + for (error of errors) { + if (!exceptWords.includes(error.before)) { // 예외처리 단어 제외 + const keyword = error.before + const range = new vscode.Range( + document.positionAt(error.start), + document.positionAt(error.end)); const diagnostic = new vscode.Diagnostic(range, error.help, vscode.DiagnosticSeverity.Error) - diagnostic.answers = error.after diagnostic.document = document diagnostic.error = error + // 문서가 편집된 후에 offset 값을 알아내기 어려우므로 추가 field에 저장해둔다. + diagnostic.startOffset = error.start; + diagnostic.endOffset = error.end; diagnostics.push(diagnostic) - - index = text.indexOf(keyword, index + 1) } - }) - + } collection.set(document.uri, diagnostics) } @@ -112,11 +160,44 @@ function getEditor () { } function onDidChangeTextDocument (event) { - const document = event.document - const errors = resultMap.get(document) + const changes = event.contentChanges; + if (!changes || changes.length==0) + return; + for(const changed of changes) { + const offsetInc = changed.text.length - changed.rangeLength; + + const diags = collection.get(event.document.uri); + const newDiags = [] + const document = event.document; + for(d of diags) { + if (d.range.end.isBeforeOrEqual(changed.range.start)) + newDiags.push(d); + else if (d.range.start.isAfterOrEqual(changed.range.end)) { + // d.range는 편집 전의 document를 기준으로 좌표를 가지고 있기 때문에 + // 지금 시점에서 document.offsetAt으로 offset을 계산할 수 없음. 때문에 별도로 저장해놓은 offset값을 이용 + const start=document.positionAt(offsetInc+d.startOffset); + const end=document.positionAt(offsetInc+d.endOffset); + d.range = new vscode.Range(start, end); + d.startOffset += offsetInc; + d.endOffset += offsetInc; + newDiags.push(d); + } else { + // diag에 접하는 영역을 편집시에는 diag를 삭제해버리자. + } + } + collection.set(event.document.uri, newDiags); + } +} + +function onDidCloseTextDocument(document) { + if (document && document.uri) { + collection.delete(document.uri); + } +} - if (errors && errors.length > 0) { - setCollections(document, errors) +function onDidSaveTextDocument(document) { + if (document && document.uri) { + collection.set(document.uri, []); } } diff --git a/src/spell-checker.js b/src/spell-checker.js index e17016c..2fde730 100644 --- a/src/spell-checker.js +++ b/src/spell-checker.js @@ -25,18 +25,26 @@ function execute (text) { } function parse (text) { + // 정상 응답인지 title로 체크 + const title = text.match(/(.*?)<\/title.*?>/i); + if (!title) + throw("Invalid response: " + text.substring(100)); + if (title[1]!="한국어 맞춤법/문법 검사기") + throw(title[1]); + // index of first opening bracket const startIndex = text.indexOf('data = [{') // index of semicolon after last closing bracket const nextIndex = text.indexOf('}];\n') - if (startIndex < 0 || nextIndex < 0) throw Error('failedToFindJson') + if (startIndex < 0 || nextIndex < 0) throw Error('맞춤법 오류를 찾지 못했습니다.') // JSON data except trailing semicolon const rawData = text.substring(startIndex + 7, nextIndex + 2) const data = JSON.parse(`{"pages":${rawData}}`) + const result = build(data.pages) - return build(data.pages) + return result; } function build (pages) { @@ -46,13 +54,12 @@ function build (pages) { pages.forEach(page => { page.errInfo.forEach(error => { const keyword = error.orgStr - - if (keywords.includes(keyword)) return - keywords.push(keyword) result.push({ after: error.candWord.split(/\s*\|\s*/).filter(s => s.length > 0), before: keyword, + start: error.start, + end: error.end, help: unescapeHtmlEntity(error.help.replace(//gi, '\n')) }) })