#!/usr/bin/ruby -w
#
# rdict - a DICT protocol client (see RFC 2229)
#
# $Id: rdict,v 1.28 2003/06/11 04:52:07 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
rdict - a DICT protocol client
= SYNOPSIS
 rdict [-h|--host server] [-p|--port service] [-d|--database dbname]
       [-m|--match] [-s|--strategy strategy] [-C|--nocorrect]
       [-D|--dbs] [-S|--strats] [-H|--serverhelp] [-i|--info dbname]
       [-I|--serverinfo] [-T|--status] [-b|--debug] [-u|--user user]
       [-k|--key key] [-v|--verbose] word1 [word2 ... wordn]
 rdict [--help|-v|--version]
= DESCRIPTION
rdict is an ((<RFC 2229|URL:ftp://ftp.isi.edu/in-notes/rfc2229.txt>)) compliant
Dictionary Server Protocol (DICT) client that provides access to dictionary
definitions from a set of natural language dictionary databases.
= OPTIONS
: -h ((*server*)) or --host ((*server*))
  Specifies the hostname for the DICT server. If no servers are specified,
  the default behavior is to try dict.org, followed by alt0.dict.org.
: -p ((*port*)) or --port ((*port*))
  Specifies the port (e.g. 2628) or service (e.g. dict) for connections.
  The default is 2628, as specified in the DICT Protocol RFC.
: -d ((*dbname*)) or --database ((*dbname*))
  Specifies a specific database to search. The default is to search all
  databases (a '*' from the DICT protocol). Note that a '!' in the DICT
  protocol means to search all of the databases until a match is found, and
  then stop searching.
: -m or --match
  Instead of printing a definition, perform a match using the specified
  strategy.
: -s ((*strategy*)) or --strategy ((*strategy*))
  Specify a matching strategy. By default, the server default match strategy
  is used. This is usually 'exact' for definitions, and a server-defined
  optimal spelling correction strategy for matches ('.' from the DICT
  protocol). The available strategies are dependent on the server
  implementation. For a list of available strategies, see the -S or --strats
  option.
: -C or --nocorrect
  Usually, if a definition is requested and the word cannot be found, spelling
  correction is requested from the server, and a list of possible words are
  provided. This option disables the generation of this list.
: -D or --dbs
  Query the server and display a list of available databases.
: -S or --strats
  Query the server and display a list of available search strategies.
: -H or --serverhelp
  Query the server and display the help information that it provides.
: -i ((*dbname*)) or --info ((*dbname*))
  Request information on the specified database (usually the server will
  provide origination, descriptive or other information about the database or
  its contents).
: -I or --serverinfo
  Query the server and display information about the server.
: -T or --status
  Query the server for status information.
: -u ((*user*)) or --user ((*user*))
  Specifies the username for authentication.
: -k ((*key*)) or --key ((*key*))
  Specifies the shared secret for authentication.
: -V or --version
  Display version information.
: --help
  Display help information.
: -v or --verbose
  Be verbose.
: -b or --debug
  Display debugging information. This is long-winded, as the entire protocol
  exchange will be dumped.
= EXAMPLES
: $ rdict -D
  This will provide you with a list of databases you can query.
: $ rdict -S
  This will provide you with a list of strategies you can employ to match
  words.
: $ rdict -m -s prefix foo
  This shows you a list of all words that begin with 'foo' in all of the
  databases.
: $ rdict -s re '^(cu|ke)rb$'
  This shows you all the definitions relating to both 'curb' and 'kerb' from
  all the databases. The 're' strategy allows regular expression matching.
: $ rdict -m -s suffix fix
  This shows a list of all words that end in 'fix' in all of the databases.
: $ rdict -d jargon -m -s prefix ''
  This displays a list of all the entries in the 'jargon' database.
= 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/>))
* ((<dict(3)>))
* ((<"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
* MIME is not implemented
* command pipelining is not implemented
* URL parsing is not implemented

=end

require 'dict'
require 'getoptlong'

DEFAULT_SERVERS = %w(dict.org alt0.dict.org)
DEFAULT_STRATEGY = 'exact'
PROGRAM_NAME = File::basename($0)
PROGRAM_VERSION = '0.9.2'

class Optlist
  attr_reader :correct, :database, :dbs, :debug, :host, :key, :info, :match,
	      :port, :serverhelp, :serverinfo, :status, :strategy, :strats,
	      :user, :match_strategy, :verbose

  def initialize
    begin
      @correct		= true
      @database		= DICT::ALL_DATABASES
      @dbs		= false
      @debug		= false
      @host		= DEFAULT_SERVERS
      @key		= nil
      @info		= nil
      @match		= false
      @match_strategy	= DICT::DEFAULT_MATCH_STRATEGY
      @port		= DICT::DEFAULT_PORT
      @serverhelp	= false
      @serverinfo	= false
      @status		= false
      @strategy		= DEFAULT_STRATEGY
      @strats		= false
      @user		= nil
      @verbose		= false

      opt = GetoptLong.new(
	[ '--debug',		'-b',	GetoptLong::NO_ARGUMENT ],
	[ '--database',		'-d',	GetoptLong::REQUIRED_ARGUMENT ],
	[ '--dbs',		'-D',	GetoptLong::NO_ARGUMENT ],
	[ '--help',			GetoptLong::NO_ARGUMENT ],
	[ '--host',		'-h',	GetoptLong::REQUIRED_ARGUMENT ],
	[ '--info',		'-i',	GetoptLong::REQUIRED_ARGUMENT ],
	[ '--key',		'-k',	GetoptLong::REQUIRED_ARGUMENT ],
	[ '--match',		'-m',	GetoptLong::NO_ARGUMENT ],
	[ '--nocorrect',	'-C',	GetoptLong::NO_ARGUMENT ],
	[ '--port',		'-p',	GetoptLong::REQUIRED_ARGUMENT ],
	[ '--serverhelp',	'-H',	GetoptLong::NO_ARGUMENT ],
	[ '--serverinfo',	'-I',	GetoptLong::NO_ARGUMENT ],
	[ '--status',		'-T',	GetoptLong::NO_ARGUMENT ],
	[ '--strategy',		'-s',	GetoptLong::REQUIRED_ARGUMENT ],
	[ '--strats',		'-S',	GetoptLong::NO_ARGUMENT ],
	[ '--user',		'-u',	GetoptLong::REQUIRED_ARGUMENT ],
	[ '--verbose',		'-v',	GetoptLong::NO_ARGUMENT ],
	[ '--version',		'-V',	GetoptLong::NO_ARGUMENT ]
      )

      opt.each_option do |name, arg|
	case name
	when '--debug'		then	@debug = true
	when '--database'	then	@database = arg
	when '--dbs'		then	@dbs = true
	when '--help'		then	usage
	when '--host'		then	@host = arg
	when '--key'		then	@key = arg
	when '--info'		then	@info = arg
	when '--match'		then	@match = true
	when '--nocorrect'	then	@correct = false
	when '--port'		then	@port = arg
	when '--serverhelp'	then	@serverhelp = true
	when '--serverinfo'	then	@serverinfo = true
	when '--status'		then	@status = true
	when '--strategy'	then	@strategy = arg; @match_strategy = arg
	when '--strats'		then	@strats = true
	when '--user'		then	@user = arg
	when '--verbose'	then	@verbose = true
	when '--version'	then	version
	end
      end

    rescue GetoptLong::InvalidOption, GetoptLong::MissingArgument
      usage(1)
    end
  end

end


# display usage message and exit
#
def usage(code = 0)
  $stderr.puts <<EOF
Usage:  #{PROGRAM_NAME} [-h|--host server] [-p|--port service] [-d|--database dbname]
	      [-m|--match] [-s|--strategy strategy] [-C|--nocorrect]
	      [-D|--dbs] [-S|--strats] [-H|--serverhelp] [-i|--info dbname]
	      [-I|--serverinfo] [-T|--status] [-b|--debug] [-u|--user user]
	      [-k|--key key] [-v|--verbose] word1 [word2 ... wordn]
	#{PROGRAM_NAME} [--help|-v|--version]
EOF

  exit code
end


# display version and copyright message, then exit
#
def version
  $stderr.puts <<EOF
#{PROGRAM_NAME} #{PROGRAM_VERSION}

Copyright (C) 2002-2003 Ian Macdonald <ian@caliban.org>
This is free software; see the source for copying conditions.
There is NO warranty; not even for MERCHANTABILITY or FITNESS
FOR A PARTICULAR PURPOSE, to the extent permitted by law.
EOF

  exit
end


# tabulate databases and strategies
#
def tabulate(data_type, data)
  printf("%d %s available:\n", data.size, data_type)
  data.each { |k, v| printf("  %-11s%s\n", k, v) }
end


# show matches
#
def show_matches(match, header = nil)
  puts header if header
  yield if block_given?

  screen_width = 80
  match_separator = '  '

  match.each do |db, definitions|
    # quote multi-word matches
    definitions.each { |definition| definition.sub!(/^(.* .*)$/, "\"\\1\"") }
    line = "%s:  %s\n" % [ db, definitions.join(match_separator) ]

    # wrap display of definitions at screen_width characters
    while line.length > screen_width - match_separator.length
      cutoff = line[0 .. screen_width - 1].rindex(match_separator)
      puts line[0 .. cutoff - 1]
      print match_separator
      line = line[cutoff + 2, line.length - cutoff - 1]
    end

    puts line
  end

end


# show definitions
#
def show_definitions(definitions, header = nil)
  puts header if header

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


# take care of pluralising nouns
#
def pluralise(ending, count)
  count == 1 ? '' : ending
end


# check for status code errors
#
def check_code(code, message)
  case code
  when DICT::INVALID_DATABASE
    die(code, 'Invalid database. Use -D|--dbs for a list.')
  when DICT::ILLEGAL_PARAMETERS
    die(code, message)
  when DICT::INVALID_STRATEGY
    die(code, "Invalid search strategy. Use -S|--strats for a list.")
  when DICT::AUTH_DENIED
    $stderr.puts "Authentication denied"
  end
end


# raise an exception and exit
#
def die(code, message)
  raise ProtocolError, "(%s) %s" % [ code, message ]
end

# start of main program

option = Optlist.new

# connect
dict = DICT.new(option.host, option.port, option.debug, option.verbose)

# send client info
dict.client("%s v%s" % [ PROGRAM_NAME, PROGRAM_VERSION ])

# authorise if possible, but don't die on failure
dict.auth(option.user, option.key) if option.user && option.key
check_code(dict.code, dict.message)

# informational options
if option.info
  resp = dict.show_info(option.info)
  puts resp unless resp.nil?
  check_code(dict.code, dict.message)
end

if option.serverinfo
  resp = dict.show_server
  puts resp unless resp.nil?
  check_code(dict.code, dict.message)
end

if option.dbs
  unless (resp = dict.show_db).nil?
    check_code(dict.code, dict.message)
    tabulate("databases", resp)
  end
end

if option.strats
  unless (resp = dict.show_strat).nil?
    check_code(dict.code, dict.message)
    tabulate("strategies", resp)
  end
end

# status option
puts dict.status if option.status

# server-side help option
puts "Server help:", dict.help if option.serverhelp

exit if ARGV.empty? && option.dbs || option.strats || option.info ||
	option.serverinfo || option.status || option.serverhelp

usage if ARGV.empty?

ARGV.each do |word|
  if option.match  # perform match
    match = dict.match(option.database, option.match_strategy, word)
    check_code(dict.code, dict.message)

    printf(%Q(No matches found for "%s"\n), word) unless match

    if match
      show_matches(match) do
	count = 0
	match.each_value { |definitions| count += definitions.size }
	printf(%Q(\n%d definition%s for "%s" found in %d database%s\n\n),
	       count, pluralise('s', count), word, match.size,
	       pluralise('s', match.size))
      end
    end

    next
  end

  # check for non-default matching strategy
  if option.strategy != DEFAULT_STRATEGY	# match to get list of words
    match = dict.match(option.database, option.match_strategy, word)
    check_code(dict.code, dict.message)

    if match
      match.each do |db, words|  # iterate over databases
	words.each do |w|	 # iterate over words
	  definitions = dict.define(db, w)
	  show_definitions(definitions) if definitions
	end
      end
    end

    next
  end
      
  # look up definitions
  definitions = dict.define(option.database, word)
  check_code(dict.code, dict.message)

  if definitions
    show_definitions(definitions, "%d definition%s found" %
		     [ definitions.size, pluralise('s', definitions.size) ])
  else
    printf('No definitions found for "%s"', word)
    if option.correct			# perform correction
      match = dict.match(option.database, DICT::DEFAULT_MATCH_STRATEGY, word)
      show_matches(match, ', perhaps you mean:') if match
      puts unless match
    else
      puts
    end
  end
end

dict.disconnect
