# dict.rb - a client-side implementation of the DICT protocol (RFC 2229)
#
# $Id: dict.rb,v 1.25 2003/04/19 06:53:41 ianmacd Exp $
# 
# Version : 0.9.2
# Author  : Ian Macdonald <ian@caliban.org>
#
# Copyright (C) 2002-2003 Ian Macdonald
#
#   This program is free software; you can redistribute it and/or modify
#   it under the terms of the GNU General Public License as published by
#   the Free Software Foundation; either version 2, or (at your option)
#   any later version.
#
#   This program is distributed in the hope that it will be useful,
#   but WITHOUT ANY WARRANTY; without even the implied warranty of
#   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#   GNU General Public License for more details.
#
#   You should have received a copy of the GNU General Public License
#   along with this program; if not, write to the Free Software Foundation,
#   Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.

=begin

= NAME
Ruby/DICT - client-side DICT protocol library
= SYNOPSIS

  require 'dict'

  dict = DICT.new('dict.org', DICT::DEFAULT_PORT)
  dict.client('a Ruby/DICT client')
  definitions = dict.define(DICT::ALL_DATABASES, 'ruby')

  if definitions
    definitions.each do |d|
      printf("From %s [%s]:\n\n", d.description, d.database)
      d.definition.each { |line| print line }
    end
  end

  dict.disconnect

= DESCRIPTION
Ruby/DICT is a client-side library implementation of the DICT protocol,
as described in ((<RFC 2229|URL:ftp://ftp.isi.edu/in-notes/rfc2229.txt>)).
= CLASS METHODS
--- DICT.new(hosts, port = DICT::DEFAULT_PORT, debug = false, verbose = false)
    This creates a new instance of the DICT class. A DICT object has four
    instance variables: ((|capabilities|)), ((|code|)), ((|message|)) and
    ((|msgid|)). ((|capabilities|)) is an array of Strings relating to
    capabilities implemented on the server, ((|code|)) is the last status
    code returned by the server, ((|message|)) is the text of the message
    related to ((|code|)), and ((|msgid|)) is the message ID returned by the
    server.
= INSTANCE METHODS
--- DICT#disconnect
    Disconnect from the server.
--- DICT#define(database, word)
    Obtain definitions for ((|word|)) from ((|database|)). A list of valid
    databases can be obtained using DICT#show(DICT::DATABASES).

    To show just the first definition found, use ((|DICT::FIRST_DATABASE|))
    as the database name. To show definitions from all databases, use
    ((|DICT::ALL_DATABASES|)).

    On success, this returns an array of Struct:Definition objects.
    ((*nil*)) is returned on failure.
--- DICT#match(database, strategy, word)
    Obtain matches for ((|word|)) from ((|database|)) using ((|strategy|)).

    On success, a hash of arrays is returned. The keys of the hash are the
    database names and the values are arrays of word matches that were found
    in that database. ((*nil*)) is returned on failure.
--- DICT#show_server
    This method retrieves information on the server itself.

    A String is returned on success, while ((*nil*)) is returned on failure.
--- DICT#show_db
    This method retrieves information on the databases offered by the server.

    A Hash indexed on database name and containing database descriptions
    is returned on success, while ((*nil*)) is returned on failure.
--- DICT#show_info(database)
    This method retrieves information on a particular database offered by
    the server.

    A String is returned on success, while ((*nil*)) is returned on failure.
--- DICT#show_strat
    This method retrieves information on the strategies offered by the server.

    A Hash indexed on strategy name and containing strategy descriptions
    is returned on success, while ((*nil*)) is returned on failure.
--- DICT#status
    This method returns a single line String of status information from the
    server.
--- DICT#help
    This method returns a String of help information from the server,
    describing the commands it implements.
--- DICT#client
    This method sends a single line String of information describing a client
    application to the server.
--- DICT#auth(user, secret)
    This method attempts to authenticate ((|user|)) to the server using
    ((|secret|)). Note that ((|secret|)) is not literally passed to the server.
= CONSTANTS
Ruby/DICT uses a lot of constants, mostly for the status codes
returned by DICT servers. See the source for details.

Some of the more interesting other constants:
: DICT::FIRST_DATABASE
  Define or match, stopping at first database where match is found
: DICT::ALL_DATABASES
  Define or match, gathering matches from all databases
: DICT::DEFAULT_MATCH_STRATEGY
  Match using a server-dependent default strategy, which should be the best
  strategy available for interactive spell checking
: DICT::DEFAULT_PORT
  The default port used by DICT servers, namely 2628
: DICT::ERROR
  A Regex constant matching any server status code indicating an error
= EXCEPTIONS
Exception classes are subclasses of the container class DICTError, which is,
itself, a subclass of RuntimeError
--- ConnectError.new(message, code = 2)
    A ConnectError is raised if DICT::new is unable to connect to the chosen
    DICT server for any reason. Program execution will terminate.
--- ProtocolError.new(message, code = 3)
    A ProtocolError exception can be used if a server operation returns a
    status code matching DICT::ERROR. This does not happen automatically. The
    code is stored in the ((|code|)) attribute of the instance of the DICT
    object. Program execution will terminate.
= AUTHOR
Written by Ian Macdonald <ian@caliban.org>
= COPYRIGHT
 Copyright (C) 2002-2003 Ian Macdonald

 This is free software; see the source for copying conditions.
 There is NO warranty; not even for MERCHANTABILITY or FITNESS
 FOR A PARTICULAR PURPOSE.
= SEE ALSO
* ((<"Ruby/DICT home page - http://www.caliban.org/ruby/"|URL:http://www.caliban.org/ruby/>))
* ((<"The DICT development group - http://www.dict.org/"|URL:http://www.dict.org/>))
* ((<"RFC 2229 - ftp://ftp.isi.edu/in-notes/rfc2229.txt"|URL:ftp://ftp.isi.edu/in-notes/rfc2229.txt>))
= BUGS
Send all bug reports, enhancement requests and patches to the
author.
= HISTORY
$Id: dict.rb,v 1.25 2003/04/19 06:53:41 ianmacd Exp $

=end


require 'socket'
require 'md5'


# lines that start with .. need to be reduced to .
#
class String
  def undot!
    sub!(/^\.\./, '.')
  end
end


# a basic exception class for DICT errors
#
class DICTError < RuntimeError
  def initialize(message, code = 1)
    $stderr.puts message
    exit code
  end
end


# deal with connection errors
#
class ConnectError < DICTError
  def initialize(message, code = 2)
    super
  end
end


# deal with status code errors
#
class ProtocolError < DICTError
  def initialize(message, code = 3)
    super
  end
end

# a structure for definitions
#
Definition = Struct.new('Definition', :word, :definition, :database,
			:description)

class DICT
  attr_reader :capabilities, :code, :message, :msgid

  DATABASES_PRESENT		= '110'
  STRATEGIES_AVAILABLE		= '111'
  DATABASE_INFORMATION		= '112'
  HELP_TEXT			= '113'
  SERVER_INFORMATION		= '114'
  CHALLENGE_FOLLOWS		= '130'
  DEFINITIONS_RETRIEVED		= '150'
  WORD_DEFINITION		= '151'
  MATCHES_PRESENT		= '152'
  STATUS_RESPONSE		= '210'
  CONNECTION_ESTABLISHED	= '220'
  CLOSING_CONNECTION		= '221'
  AUTHENTICATION_SUCCESSFUL	= '230'
  OK				= '250'
  SEND_RESPONSE			= '330'
  TEMPORARILY_UNAVAILABLE	= '420'
  SHUTTING_DOWN			= '421'
  UNRECOGNISED_COMMAND		= '500'
  ILLEGAL_PARAMETERS		= '501'
  COMMAND_NOT_IMPLEMENTED	= '502'
  PARAMETER_NOT_IMPLEMENTED	= '503'
  ACCESS_DENIED			= '530'
  AUTH_DENIED			= '531'
  UNKNOWN_MECHANISM		= '532'
  INVALID_DATABASE		= '550'
  INVALID_STRATEGY		= '551'
  NO_MATCH 			= '552'
  NO_DATABASES_PRESENT		= '554'
  NO_STRATEGIES_AVAILABLE	= '555'

  ALL_DATABASES			= '*'
  DEFAULT_MATCH_STRATEGY	= '.'
  DEFAULT_PORT			= 2628
  ERROR				= /^[45]/
  FIRST_DATABASE		= '!'
  MAX_LINE_LENGTH 		= 1024
  PAIR				= /^(\S+)\s"(.+)"\r$/
  REPLY_CODE			= /^\d\d\d/

  def initialize(hosts, port = DEFAULT_PORT, debug = false, verbose = false)
    hosts.each do |host|
      @debug = debug
      @verbose = verbose
      printf("Attempting to connect to %s:%d...\n", host, port) if @verbose

      begin
	@sock = TCPSocket.open(host, port)
      rescue
	next	# cycle through list of servers, if more than one
      end

      break	# continue if connection to this host succeeded
    end

    # catch failure
    raise ConnectError, 'Unable to connect to host' unless defined? @sock

    # check status line on connect
    line = get_line
    raise ConnectError, line if line =~ ERROR

    caps, @msgid = /<(.+?)>\s(<.+>)/.match(line)[1..2]
    @capabilities = caps.split(/\./)
    if @verbose
      printf("Capabilities: %s\n", @capabilities.join(', '))
      printf("Msgid: %s\n", @msgid)
    end
  end

  private

  # output a line to the server
  #
  def send_line(command)
    line = command + "\r\n"
    $stderr.printf("SEND: %s", line) if @debug
    @sock.print(line)
  end

  # get a line of input from the server
  #
  def get_line
    line = @sock.readline("\r\n")
    $stderr.printf("RECV: %s", line) if @debug
    line
  end

  # send a command and get a response
  #
  def exec_cmd(command)
    send_line(command)
    line = get_line
    @code, @message = /^(\d\d\d)\s(.*)$/.match(line)[1..2]
    # remember the command just executed
    @command = command
  end

  # determine whether we're at the end of this response
  #
  def end_of_text?(line)
    line =~ /^\.\r$/ ? true : false
  end

  # generic method to issue command and parse response
  #
  def parse_response
    return nil if @code =~ ERROR

    while line = get_line
      # on first pass through loop, create list as either a hash
      # or a string, depending # on what data looks like
      list ||= line =~ PAIR ? Hash.new : ''

      # check for end of data
      return list if line =~ REPLY_CODE

      if ! end_of_text? line
	line.undot!
	(list << line; next) if list.is_a?(String)  # list is just text

	# list is a hash of data pairings
	name, desc = PAIR.match(line)[1..2]
	if @command =~ /^MATCH/
	  list[name] = Array.new unless list[name]
	  list[name] << desc
	else
	  list[name] = desc
	end

      end
    end
  end

  public

  # QUIT from the server
  #
  def disconnect
    exec_cmd('QUIT')
    @sock.close
  end

  # DEFINE a word
  #
  def define(db, word)
    definitions = Array.new
    d = Definition.new
    d.word = word
    d.definition = Array.new

    exec_cmd('DEFINE %s "%s"' % [ db, word ])

    return nil if @code =~ ERROR

    while line = get_line
      return definitions if line =~ /^#{OK}/

      if line =~ /^#{WORD_DEFINITION}/
	word, d.database, d.description =
	  /^\d\d\d\s"(.+?)"\s(\S+)\s"(.+)"\r$/.match(line)[1..3]
      elsif end_of_text? line	# finish definition and start a new one
	definitions << d
	d = Definition.new
	d.word = word
	d.definition = Array.new
      else
	line.undot!
	d.definition << line
      end

    end
  end

  # MATCH a word
  #
  def match(db, strategy, word)
    exec_cmd('MATCH %s %s "%s"' % [ db, strategy, word ])
    parse_response
  end

  # get database list
  #
  def show_db
    exec_cmd("SHOW DB")
    parse_response
  end

  # get strategy list
  #
  def show_strat
    exec_cmd("SHOW STRAT")
    parse_response
  end

  # get information on database
  #
  def show_info(db)
    exec_cmd('SHOW INFO %s' % db)
    parse_response
  end

  # get server information
  #
  def show_server
    exec_cmd("SHOW SERVER")
    parse_response
  end
  
  # request server STATUS information
  #
  def status
    exec_cmd('STATUS')
    @message
  end

  # request server-side HELP
  #
  def help
    exec_cmd('HELP')
    parse_response
  end

  # send CLIENT information
  #
  def client(info)
    exec_cmd('CLIENT %s' % info)
  end

  # AUTHorise user
  #
  def auth(user, secret)
    auth = MD5::new(@msgid + secret).hexdigest
    exec_cmd('AUTH %s %s' % [ user, auth ])
  end

end
