/*
* $Id$
*
* Copyright (c) 2004-2005 by the TeXlapse Team.
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the Eclipse Public License v1.0
* which accompanies this distribution, and is available at
* http://www.eclipse.org/legal/epl-v10.html
*/
package net.sourceforge.texlipse.spelling;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.io.UnsupportedEncodingException;
import java.text.MessageFormat;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import net.sourceforge.texlipse.PathUtils;
import net.sourceforge.texlipse.SelectedResourceManager;
import net.sourceforge.texlipse.TexlipsePlugin;
import net.sourceforge.texlipse.builder.BuilderRegistry;
import net.sourceforge.texlipse.properties.TexlipseProperties;
import org.eclipse.core.resources.IFile;
import org.eclipse.core.resources.IMarker;
import org.eclipse.core.resources.IProject;
import org.eclipse.core.resources.IResource;
import org.eclipse.core.resources.WorkspaceJob;
import org.eclipse.core.runtime.CoreException;
import org.eclipse.core.runtime.IProgressMonitor;
import org.eclipse.core.runtime.IStatus;
import org.eclipse.core.runtime.Status;
import org.eclipse.core.runtime.jobs.Job;
import org.eclipse.jface.preference.IPreferenceStore;
import org.eclipse.jface.text.BadLocationException;
import org.eclipse.jface.text.IDocument;
import org.eclipse.jface.text.Position;
import org.eclipse.jface.text.contentassist.ICompletionProposal;
import org.eclipse.jface.text.source.IAnnotationModel;
import org.eclipse.jface.text.source.ISourceViewer;
import org.eclipse.jface.util.IPropertyChangeListener;
import org.eclipse.jface.util.PropertyChangeEvent;
import org.eclipse.ui.IMarkerResolution;
import org.eclipse.ui.texteditor.AbstractMarkerAnnotationModel;
/**
* An abstraction to a spell checker program.
*
* @author Kimmo Karlsson
* @author Georg Lippold
* @author Boris von Loesch
*/
public class SpellChecker implements IPropertyChangeListener {
// marker type for the spelling errors
public static final String SPELLING_ERROR_MARKER_TYPE = TexlipseProperties.PACKAGE_NAME + ".spellingproblem";
// preference constants
public static final String SPELL_CHECKER_COMMAND = "spellCmd";
public static final String SPELL_CHECKER_ARGUMENTS = "spellArgs";
public static final String SPELL_CHECKER_ENV = "spellEnv";
private static final String ASPELL_ENCODING = "UTF-8";
// These two strings have to have multiple words, because otherwise
// they may come up in aspells proposals.
public static String SPELL_CHECKER_ADD = "spellCheckerAddToUserDict";
// The values are resource bundle entry IDs at startup.
// They are converted to actual strings in the constructor.
public static String SPELL_CHECKER_IGNORE = "spellCheckerIgnoreWord";
/**
* A Spell-checker job.
*/
static class SpellCheckJob extends WorkspaceJob {
// the document to check for spelling
private IDocument document;
// the file that contains the document
private IFile file;
/**
* Create a new spell-checker job for the given document.
* @param name document name
* @param doc document
*/
public SpellCheckJob(String name, IDocument doc, IFile file) {
super(name);
document = doc;
this.file = file;
}
/**
* Run the spell checker.
*/
public IStatus runInWorkspace(IProgressMonitor monitor) {
SpellChecker.checkSpellingDirectly(document, file, monitor);
return new Status(IStatus.OK, TexlipsePlugin.getPluginId(), IStatus.OK, "ok", null);
}
}
// the shared instance
private static SpellChecker instance = new SpellChecker();
// the external spelling program
private Process spellProgram;
// the stream to the program
private PrintWriter output;
// the stream from the program
private BufferedReader input;
// spelling program command with arguments
private String command;
// environment variables for the program
private String[] envp;
// map of proposals so far
private Map<IMarker, String[]> proposalMap;
// the current language
private String language;
/**
* Private constructor, because we want to keep this singleton.
*/
private SpellChecker() {
proposalMap = new HashMap<IMarker, String[]>();
language = "en";
// these two must be initialized in the constructor, otherwise the resource bundle may not be initialized
SPELL_CHECKER_ADD = TexlipsePlugin.getResourceString(SPELL_CHECKER_ADD);
SPELL_CHECKER_IGNORE = TexlipsePlugin.getResourceString(SPELL_CHECKER_IGNORE);
}
/**
* Initialize the spell checker. This method must be called only once.
* Preferably from PreferenceInitializer.
*
* @param prefs the plugin preferences
*/
public static void initializeDefaults(IPreferenceStore prefs) {
String aspell = PathUtils.findEnvFile("aspell", "/usr/bin", "aspell.exe", "C:\\gnu\\aspell");
prefs.setDefault(SPELL_CHECKER_COMMAND, aspell);
// -a == ispell compatibility mode, -t == tex mode
prefs.setDefault(SPELL_CHECKER_ENV, "");
prefs.setDefault(SPELL_CHECKER_ARGUMENTS,
"-a -t --lang=%language --encoding=%encoding");
prefs.addPropertyChangeListener(instance);
instance.readSettings();
}
/**
* Add the given word to Aspell user dictionary.
* @param word word to add
*/
public static void addWordToAspell(String word) {
//patch 1537979 by daniel309
if (instance.command == null) instance.readSettings();
String cmd = instance.command;
BuilderRegistry.printToConsole("aspell> adding word: " + word);
String[] environp = PathUtils.mergeEnvFromPrefs(PathUtils.getEnv(),
SPELL_CHECKER_ENV);
try {
Process p = Runtime.getRuntime().exec(cmd, environp);
PrintWriter w = new PrintWriter(new
OutputStreamWriter(p.getOutputStream(), ASPELL_ENCODING));
w.println("*" + word);
w.println("#");
w.flush();
w.close();
p.getOutputStream().close();
p.waitFor();
} catch (Exception e) {
BuilderRegistry.printToConsole("Error adding word \""
+ word + "\" to Aspell user dict\n");
TexlipsePlugin.log("Adding word \""
+ word + "\" to Aspell user dict", e);
}
}
/**
* Read settings from the preferences.
*/
private void readSettings() {
command = null;
envp = null;
String path = TexlipsePlugin.getPreference(SPELL_CHECKER_COMMAND);
if (path == null || path.length() == 0) {
return;
}
File f = new File(path);
if (!f.exists() || f.isDirectory()) {
return;
}
String args = TexlipsePlugin.getPreference(SPELL_CHECKER_ARGUMENTS);
args = args.replaceAll("%encoding", ASPELL_ENCODING);
args = args.replaceAll("%language", language);
command = f.getAbsolutePath() + " " + args;
envp = PathUtils.mergeEnvFromPrefs(PathUtils.getEnv(), SPELL_CHECKER_ENV);
}
/**
* Check that the current language setting is correct.
*/
private void checkLanguage(IFile file) {
String pLang = null;
IProject prj = file.getProject();
if (prj != null) {
pLang = TexlipseProperties.getProjectProperty(prj, TexlipseProperties.LANGUAGE_PROPERTY);
}
boolean restart = false;
if (pLang != null && pLang.length() > 0) {
if (!pLang.equals(language)) {
// current project is different language
//than currently running process, so change
language = pLang;
restart = true;
}
}
if (restart) {
stopProgram();
readSettings();
}
}
/**
* Check if the spelling program is still running.
* Restart the program, if necessary.
* @param file
*/
protected boolean checkProgram(IFile file) {
checkLanguage(file);
if (spellProgram == null) {
return startProgram();
} else {
int exitCode = -1;
try {
exitCode = spellProgram.exitValue();
} catch (IllegalThreadStateException e) {
// program is still running, good
}
if (exitCode != -1) {
// an exit code is defined, so program has ended
spellProgram = null;
return startProgram();
}
}
return false;
}
/**
* Restarts the spelling program.
* Assumes that the program is not currently running.
*/
private boolean startProgram(){
BuilderRegistry.printToConsole(TexlipsePlugin.getResourceString("viewerRunning") + ' ' + command);
try {
if (command == null) throw new IOException();
spellProgram = Runtime.getRuntime().exec(command, envp);
} catch (IOException e) {
spellProgram = null;
input = null;
output = null;
BuilderRegistry.printToConsole(TexlipsePlugin.getResourceString("spellProgramStartError"));
return false;
}
// get output and input stream
try {
output = new PrintWriter(new
OutputStreamWriter(spellProgram.getOutputStream(), ASPELL_ENCODING));
input = new BufferedReader(new
InputStreamReader(spellProgram.getInputStream(), ASPELL_ENCODING));
}
catch (UnsupportedEncodingException e1) {
spellProgram = null;
input = null;
output = null;
BuilderRegistry.printToConsole("Unsupported encoding");
return false;
}
// read the version info
try {
String message = input.readLine();
if (null == message) { // Something went wrong, get message from aspell's error stream
BufferedReader error = new BufferedReader(new InputStreamReader(spellProgram.getErrorStream()));
message = error.readLine();
if (null == message) {
BuilderRegistry.printToConsole("Aspell failed! No output could be read.");
} else {
BuilderRegistry.printToConsole("aspell> " + message.trim());
}
error.close();
return false;
}
BuilderRegistry.printToConsole("aspell> " + message.trim());
// Now it's up and running :)
// put it in terse mode, then it's faster
output.println("!");
return true;
} catch (IOException e) {
TexlipsePlugin.log("Aspell died", e);
BuilderRegistry.printToConsole(TexlipsePlugin.getResourceString("spellProgramStartError"));
return false;
}
}
/**
* Stop running the spelling program.
*/
private void stopProgram() {
if (spellProgram != null) {
spellProgram.destroy();
spellProgram = null;
}
}
/**
* The IPropertyChangeListener method.
* Re-reads the settings from preferences.
*/
public void propertyChange(PropertyChangeEvent event) {
String prop = event.getProperty();
if (prop.startsWith("spell")) {
// encoding, program args or program path changed
//BuilderRegistry.printToConsole("spelling property changed: " + prop);
stopProgram();
readSettings();
}
}
/**
* Check spelling of a single line.
*
* @param line the line of text
* @param offset start offset of the line in the document
* @param file file
* @return fix proposals, or empty array if all correct
*/
public static void checkSpelling(String line, int offset, int lineNumber, IFile file) {
if (instance.checkProgram(file)){
instance.checkLineSpelling(line, offset, lineNumber, file);
}
}
/**
* Check spelling of the entire document.
* This method returns after scheduling a spell-checker job.
* @param document document from the editor
* @param file
*/
public static void checkSpelling(IDocument document, IFile file) {
instance.startSpellCheck(document, file);
}
/**
* Check spelling of the entire document.
* This method returns after scheduling a spell-checker job.
* @param document document from the editor
*/
private void startSpellCheck(IDocument document, IFile file) {
Job job = new SpellCheckJob("Spellchecker", document, file);
job.setUser(true);
job.schedule();
}
/**
* Check spelling of the entire document.
* This method actually checks the spelling.
* @param document document from the editor
*/
private static void checkSpellingDirectly(IDocument document, IFile file, IProgressMonitor monitor) {
if (instance.checkProgram(file)) {
instance.checkDocumentSpelling(document, file, monitor);
}
}
/**
* Check spelling of the entire document.
*
* @param doc the document
* @param file
*/
private void checkDocumentSpelling(IDocument doc, IFile file, IProgressMonitor monitor) {
deleteOldProposals(file);
//doc.addDocumentListener(instance);
try {
int num = doc.getNumberOfLines();
monitor.beginTask("Check spelling", num);
for (int i = 0; i < num; i++) {
if (monitor.isCanceled()) break;
int offset = doc.getLineOffset(i);
int length = doc.getLineLength(i);
String line = doc.get(offset, length);
checkLineSpelling(line, offset, i+1, file);
monitor.worked(1);
}
} catch (BadLocationException e) {
TexlipsePlugin.log("Checking spelling on a line", e);
}
stopProgram();
}
/**
* Replaces all Latex coded umlauts like \"a, "a or \ss by the correct
* character in ISO-8859-1
* @param line
* @return
*/
private static String replaceUmlauts(String line) {
//FIXME: replacement of "a or "o is missing
StringBuilder out = new StringBuilder();
int addWS = 0;
for (int i=0; i < line.length(); i++) {
char c = line.charAt(i);
if (addWS > 0 && Character.isWhitespace(c)) {
//Correct position by adding whitespaces
for (int j = 0; j < addWS; j++) out.append(' ');
addWS = 0;
}
if (c == '\\') {
if (i+2 < line.length() && line.charAt(i+1) == 's' && line.charAt(i+2) == 's') {
if (i+3 == line.length() || Character.isWhitespace(line.charAt(i+3))
|| line.charAt(i+3) == '\\' || line.charAt(i+3) == '}') {
out.append('�');
i = i+2;
addWS = 2;
continue;
}
}
if (i+1 < line.length() && line.charAt(i+1) == '"') {
if (i+2 < line.length()) {
char c2 = line.charAt(i+2);
i = i+2;
addWS = 2;
switch (c2) {
case 'a':
out.append('�');
break;
case 'u':
out.append('�');
break;
case 'o':
out.append('�');
break;
case 'A':
out.append('�');
break;
case 'U':
out.append('�');
break;
case 'O':
out.append('�');
break;
default:
i = i-2;
addWS = 0;
out.append('\\');
break;
}
continue;
}
}
}
out.append(c);
}
return out.toString();
}
/**
* Check spelling of a single line.
* This method parses ispell-style spelling error proposals.
*
* @param line the line of text
* @param offset start offset of the line in the document
* @param file
* @return fix proposals, or empty array if all correct
*/
private void checkLineSpelling(String line, int offset, int lineNumber, IFile file) {
// check that there is text for the checker
if (line == null || line.length() == 0) {
return;
}
if (line.trim().length() == 0) {
return;
}
// give the speller something to parse
String lineToPost = line;
if (language.equals("de")) {
lineToPost = replaceUmlauts(line);
}
/*
* a prefixed "^" tells aspell to parse the line without exceptions. From
* http://aspell.sourceforge.net/man-html/Through-A-Pipe.html#Through-A-Pipe:
* "lines of single words prefixed with any of `*', `&', `@', `+', `-',
* `~', `#', `!', `%', or `^'" are also valid and have a special meaning
* Special meaning of "^" is to ignore all other prefixes.
*
*/
output.println("^" + lineToPost);
output.flush();
// wait until there is input
List<String> lines = new ArrayList<String>();
try {
String result = input.readLine();
while ((!"".equals(result)) && (result != null)) {
lines.add(result);
result = input.readLine();
}
//Wait a bit and clear the input buffer (sometimes there are more than one empty line)
try {
Thread.sleep(5);
} catch (InterruptedException e) {
//No problem
}
while (input.ready()) {
input.readLine();
}
} catch (IOException e) {
BuilderRegistry.printToConsole(TexlipsePlugin.getResourceString("spellProgramStartError"));
TexlipsePlugin.log("aspell error at line " + lineNumber + ": " + lineToPost, e);
}
// loop through the output lines (they contain only errors)
for (int i = 0; i < lines.size(); i++) {
String[] tmp = (lines.get(i)).split(":");
String[] error = tmp[0].split(" ");
String word = error[1].trim();
// column, where the word starts in the line of text
// is always the last entry in error (sometimes 3, if there
// are matches, else 2)
// we have to subtract 1 since the first char is always "^"
int column = Integer.valueOf(error[error.length - 1]).intValue() - 1;
/*
// if we have multi byte chars (e.g. umlauts in utf-8), then aspell
// returns them as multiple columns. computing the difference
// between byte-length and String-length:
byte[] bytes = lineToPost.getBytes();
byte[] before = new byte[column];
for (int j = 0; j < column; j++) {
before[j] = bytes[j];
}
int difference = column - (new String(before)).length();
column -= difference;
*/
// list of proposals starts after the colon
String[] options;
if (tmp.length > 1) {
String[] proposals = (tmp[1].trim()).split(", ");
options = new String[proposals.length + 2];
for (int j = 0; j < proposals.length; j++) {
options[j] = proposals[j].trim();
}
} else {
options = new String[2];
}
options[options.length - 2] = MessageFormat.format(SPELL_CHECKER_IGNORE,
new Object[] { word });
options[options.length - 1] = MessageFormat.format(SPELL_CHECKER_ADD,
new Object[] { word });
createMarker(file, options, offset + column, word,
lineNumber);
}
}
/**
* Adds a spelling error marker to the given file.
*
* @param file the resource to add the marker to
* @param proposals list of proposals for correcting the error
* @param charBegin beginning offset in the file
* @param wordLength length of the misspelled word
*/
private void createMarker(IResource file, String[] proposals, int charBegin, String word, int lineNumber) {
Map<String, ? super Object> attributes = new HashMap<String, Object>();
attributes.put(IMarker.CHAR_START, Integer.valueOf(charBegin));
attributes.put(IMarker.CHAR_END, Integer.valueOf(charBegin+word.length()));
attributes.put(IMarker.LINE_NUMBER, Integer.valueOf(lineNumber));
attributes.put(IMarker.SEVERITY, Integer.valueOf(IMarker.SEVERITY_WARNING));
attributes.put(IMarker.MESSAGE,
MessageFormat.format(TexlipsePlugin.getResourceString("spellMarkerMessage"),
new Object[] { word }));
try {
IMarker marker = file.createMarker(SPELLING_ERROR_MARKER_TYPE);
marker.setAttributes(attributes);
proposalMap.put(marker, proposals);
/* MarkerUtilities.createMarker(file, attributes, SPELLING_ERROR_MARKER_TYPE);
addProposal(file, charBegin, charBegin+word.length(), proposals);*/
} catch (CoreException e) {
TexlipsePlugin.log("Adding spelling marker", e);
}
}
/**
* Clear all spelling error markers.
*/
public static void clearMarkers(IResource resource) {
instance.deleteOldProposals(resource);
}
/**
* Deletes all the error markers of the previous check.
* This has to be done to avoid duplicates.
* Also, old markers are probably not anymore in the correct positions.
*/
private void deleteOldProposals(IResource res) {
// delete all markers with proposals, because there might be something in the other files
Iterator<IMarker> iter = proposalMap.keySet().iterator();
while (iter.hasNext()) {
IMarker marker = (IMarker) iter.next();
try {
marker.delete();
} catch (CoreException e) {
TexlipsePlugin.log("Deleting marker", e);
}
}
// just in case delete all markers from this file
try {
res.deleteMarkers(SPELLING_ERROR_MARKER_TYPE, false, IResource.DEPTH_ONE);
} catch (CoreException e) {
TexlipsePlugin.log("Deleting markers", e);
}
// clear the old proposals
proposalMap.clear();
}
/**
* Returns the spelling correction proposal words for the given marker.
*
* @param marker a marker
* @return correction proposals
*/
public static String[] getProposals(IMarker marker) {
return instance.proposalMap.get(marker);
}
/**
* Returns the actual position of <i>marker</i> or null if the marker was
* deleted. Code inspired by
* @param marker
* @param sourceViewer
* @return
*/
private static int[] getMarkerPosition(IMarker marker, ISourceViewer sourceViewer) {
int[] p = new int[2];
p[0] = marker.getAttribute(IMarker.CHAR_START, -1);
p[1] = marker.getAttribute(IMarker.CHAR_END, -1);
// look up the current range of the marker when the document has been edited
IAnnotationModel model= sourceViewer.getAnnotationModel();
if (model instanceof AbstractMarkerAnnotationModel) {
AbstractMarkerAnnotationModel markerModel= (AbstractMarkerAnnotationModel) model;
Position pos= markerModel.getMarkerPosition(marker);
if (pos != null && !pos.isDeleted()) {
// use position instead of marker values
p[0] = pos.getOffset();
p[1] = pos.getOffset() + pos.getLength();
}
if (pos != null && pos.isDeleted()) {
// do nothing if position has been deleted
return null;
}
}
return p;
}
/**
* Finds the spelling correction proposals for the word at the given offset.
*
* @param offset text offset in the current file
* @return completion proposals, or null if there is no marker at the given offset
*/
public static ICompletionProposal[] getSpellingProposal(int offset, ISourceViewer sourceViewer) {
IResource res = SelectedResourceManager.getDefault().getSelectedResource();
if (res == null) {
return null;
}
IMarker[] markers = null;
try {
markers = res.findMarkers(SPELLING_ERROR_MARKER_TYPE, false, IResource.DEPTH_ZERO);
} catch (CoreException e) {
return null;
}
for (int i = 0; i < markers.length; i++) {
int[] p = getMarkerPosition(markers[i], sourceViewer);
if (p != null && p[0] <= offset && offset <= p[1]) {
try {
//Update marker's position
markers[i].setAttribute(IMarker.CHAR_START, p[0]);
markers[i].setAttribute(IMarker.CHAR_END, p[1]);
} catch (CoreException e) {
TexlipsePlugin.log("Error while updating Marker", e);
}
SpellingResolutionGenerator gen = new SpellingResolutionGenerator();
return convertAll(gen.getResolutions(markers[i]), markers[i]);
}
}
return null;
}
/**
* Converts the given marker resolutions to completion proposals.
*
* @param resolutions marker resolutions
* @param marker marker that holds the given resolutions
* @return completion proposals for the given marker
*/
private static ICompletionProposal[] convertAll(IMarkerResolution[] resolutions, IMarker marker) {
ICompletionProposal[] array = new ICompletionProposal[resolutions.length];
for (int i = 0; i < resolutions.length; i++) {
SpellingMarkerResolution smr = (SpellingMarkerResolution) resolutions[i];
array[i] = new SpellingCompletionProposal(smr.getSolution(), marker);
}
return array;
}
}