#!/usr/bin/env zsh
Version=3.00
Myname="${0##*/}"
Mypath="${0:A}"

doc=$(cat \
<<'DOC'
= ltxfileinfo - print version information for a LaTeX file

= Synopsis
ltxfileinfo [options] filename	

= Options
-h,--help	print this help and exit
-H,--Help	print full documentation via less and exit
-V		print script's version and exit
-f,--flat	output as 1 line with 6 tab-separated fields:
		error code, file, date, version, encoding and info;
		With the |--verbose| option a 7th field is printed: 
		the full path of the file.
-m,--mark	mark wrong or unusual elements in \Provides...
-k,--key=X	with |--key=X|, where X can be one of f,t,d,v,e,i or l,	 
		print the corresponding field: file, type, date, version,
		encoding, info or location respectively.
-v,--verbose	print debugging info

= Description
ltxfileinfo displays version information for LaTeX files. If no path
information is given, the file is searched using kpsewhich. As an extra,
for developers, the script will check the valididity of the |\Provides...|
statement in the files.

Without an option, the output will be of the form:
  $ ltxfileinfo ctable.sty
  file: ctable
  type: Package
  date: 2025/07/10
  vers: v1.32
  enco: us-ascii
  info: package for flexible key/value driven typesetting of floats
  loca: /texlive/texmf-local/tex/latex/ctable/ctable.sty

With the |--mark| option, missing information is represented by |--*|,
wrong dates and odd-formatted version data will be marked with a |*|:

  $ ltxfileinfo -m yhmath.sty
  file: yhmath
  type: Package
  date: 2020/03/17
  vers: v1.6
  enco: us-ascii
  info: --* (absent)
  loca: /texlive/2026/texmf-dist/tex/latex/yhmath/yhmath.sty

If the terminal supports color, it colorizes such wrong
data with red. This is useful for developers who want to check the
correctness of their |\Provides...| statements. The number of errors
detected (upto 4) will be reflected in the exit status. For example:

  $ ltxfileinfo -m  arfonts.sty
  file: ARfonts* (should be arfonts)
  type: Package
  date: 2006/01/01
  vers: --* (absent)
  enco: us-ascii
  info: Part of the Arabi package
  loca: /texlive/2026/texmf-dist/tex/latex/arabi/arfonts.sty

We see here, that the |\ProvidesPackage| statement has an incorrect first
argument and has no version information. The exit status will be 10.

= Other output formats
With the |--date| option, only the file's date will be shown, unlabeled.
The |--version|, |--location|, |--info| options are treated analogously.

  $ ltxfileinfo -v chronology.sty
  v2.0

The |--flat| option prints an error code and the fields (except loca:, the
last field) on one line, unlabeled and tab-separated:
 
  $ ltxfileinfo --flat chronology.sty
  00000	chronology   sty   2023/08/20	v2.0	us-ascii   - Horizontal Timeline

= Bugs

On my system, I have a total of 16872 files that contain a |\Provides...|
statement. I ran them all through |ltxfileinfo| and made the following
summary of detected errors:

 16872  Total \Provides... containing files tested 
  6384  showed no errors
  2518  had missing info-fields
   366  had encodings other than utf-8 or us-ascii
  8477  had missing or badly formatted versions
  2919  had missing or badly formatted dates
  1193  had incorrect first argument in \Provides statement

= Author
[Wybo Dekker](wybodekker@me.com)

= Copyright
Released under the [GNU General Public License](www.gnu.org/copyleft/gpl.html)
DOC
)

set -uo pipefail 
 
# --- colors, help from embedded doc, die ---
[ -t 1 ] && { Red=$'\e[1;31m' Mag=$'\e[1;35m' Nor=$'\e[0m'; } || Red='' Mag='' Nor=''
nv="-$Myname-${Version/./\\.} (press h for help, q to quit)"
helpsrt() { sed -n '/^= Synopsis/,/^= Desc/{//d;p}' <<<"$doc"; exit; }
helpall() { less -P"$nv" <<<"$doc"; exit; }
    die() { printf "%s$Nor\n" "${@/#/$Myname: $Red}" >&2; exit 1; }
   Warn() { printf "%s$Nor\n" "${@/#/$Myname: $Mag}" >&2; }
   warn() { $verbose && Warn "$@"; }
     pr() { printf '%s\n' $@ }

key='' mark=false vers='' nerror=0 out='' verbose=false tag='' lua=false
declare -A keys=([f]=file [t]=type [d]=date [v]=vers [e]=enco [i]=info [l]=loca)
handle_options() {
   local options
   if ! options=$(getopt\
      -n "$Myname" \
      -o hHVfmvk:lI \
      -l help,Help,flat,mark,version,verbose,key:,lua -- "$@"
   ); then exit 1; fi
   eval set -- "$options"
   flat=false
   while [ $# -gt 0 ]; do
      case $1 in
      (-h|--help)     # print this help and exit
                      helpsrt ;;
      (-H|--Help)     # print full documentation via less and exit
                      helpall ;;
      (-V)            # print script's version and exit
		      echo $Version; exit;;
      (-f|--flat)     # output as 1 line with 6 tab-separated fields:
		      # error code, file, date, version, encoding and info;
                      # With the |--verbose| option a 7th field is printed: 
                      # the full path of the file.
                      flat=true;;
      (-m|--mark)     # mark wrong or unusual elements in \\Provides...
                      mark=true;;
      (-k|--key)      # with |--key=X|, where X can be one of f,t,d,v,e,i or l,  
                      # print the corresponding field: file, type, date, version,
                      # encoding, info or location respectively.
                      key=${keys[$2]}
                      [[ -n $key ]] || die "unrecognized argument for --key option"
                      shift;;
      (-l|--lua)      # use lua script scan to create initial text
		      lua=true;;
      (-v|--verbose)  # print debugging info
                      verbose=true;;
      (-I)	      instscript "$Mypath" || die 'the -I option is for developers only'
   		      exit;;
      (--)            shift; break;;
      (*)             break;;
      esac
      shift
   done
   args=( "$@" )
}

text_extended=false
extend_text() {
   warn "extending text"
   # sometimes, the text contains \input{somefilename}, where the file named
   # somefilename contains definitions like \def\filedate{2026/12/03}. Let's
   # check this and add those definitions to the temporary file.
   inp=$(sed -nzE 's/.*\\input[{]([^}]*)[}].*/\1/p' <<<"$text")
   if [[ -n $inp ]]; then
      inp=$(kpsewhich "$inp")
      if [[ -n $inp ]]; then
         text+=$(grep '\\def\\[^{ ]*{' "$inp" 2>/dev/null)
      fi
   fi
   text_extended=true
}

handle_options "$@"
set -- "${args[@]}"

arg="$1"
ext=${arg##*.}

# find the real path
case $arg in
   (*/*) loca="$(realpath "$arg")"
         [[ -e $loca ]] || die "$loca: file does not exist";;
    ('') helpsrt;;
     (*) loca=$(kpsewhich "$arg") || die "$arg: not found by kpsewhich"
esac

# for dtx-files, isolate the driver and use that as input file
if [[ $loca == *.dtx ]]; then
   tmp=$(mktemp) || die "cannot create temporary file"

   awk '
      BEGIN { in_driver = 0 }

      # begin driver block
      /^%<\*([^>]*\|)?driver(\|[^>]*)?>/ {
         in_driver = 1
         next
      }

      # end driver block
      /^%<\/([^>]*\|)?driver(\|[^>]*)?>/ {
         in_driver = 0
         next
      }

      # single-line driver directive
      /^%<([^>]*\|)?driver(\|[^>]*)?>/ {
         sub(/^%<[^>]*>/,"")
         print
         next
      }

      # lines inside a driver block
      in_driver {
         print
      }
   ' "$loca" >"$tmp"

   input="$tmp"
else
   input="$loca"
fi

enco=$(file -b --mime-encoding "$loca")
file='' date='' vers='' info=''

if $lua; then
   text=$(./newscan "$input") # |sed -z 's/^[0-9]\+\t[0-9]\+\tP/\\P/;s/\n//g;s/\\ / /g;s/{}/ /g')
else
   text=$(
      grep -v --binary-files=text "^%" "$input" |
      sed -zE '
         s/(^|[^\\])%[^\n]*/\1/g		# remove comments
         s/(\\ |\\space|[[:space:]])+/ /g	# replace whitespace with 1 space
         s/ *([]{}[]) */\1/g		# remove space around [, ], {, }
      '
   )
   # sometimes, filename, date, version end info are set with \GitIdInfo, 
   # which sets commands like \FileDate et cetera, which are then used in
   # \ProvidesFile et cetera.
   sed -nzE 's/.*\GetIdInfo ?\$Id: ([^ ]+) ([^ ]+) ([^ ]+)[^{]*[{]([^}]+)[}].*/\1|\3|\2|\4/p' <<<"$text" |IFS='|' read file date vers info
   date=${date//-/\/}
   [[ -n $vers ]] && [[ ! $vers =~ ^v ]] && vers="v$vers"
   [[ -n $date ]] && warn '\GetIdInfo command detected'
fi

enc=$(file -b --mime-encoding - <<<"$text")
case $enc in
(us-ascii|utf-8) ;;
(binary|unknown-8bit) ;;
(*) text=$(iconv -c -f "$enc" -t "utf-8" <<<"$text" 2>/dev/null) ||
      warn "iconv failed on $enco encoded file: $loca"
esac


expl_re='\\ProvidesExpl(File|Package|Class)[{]([^}]+)[}][{]([^}]+)[}][{]([^}]+)[}][{]([^}]+)[}]'
#                                           { -name-- }  { -date-- }  { -vers-- }  { -info-- }
file_re='\\Provides(File|Package|Class)[{]([^}]+)[}]\[([^] ]+)( [^] ]+)?( [^]]*)?\]'
#                                       { -name-- }  [-date-- -vers--   -info-- ]  
minimal_re='\\Provides(Expl)?(File|Package|Class)[{]([^}]+)[}]'

if [[ $text =~ $expl_re ]]; then
   type='Expl'
elif [[ $text =~ $file_re ]]; then
   type=''
elif [[ $text =~ $minimal_re ]]; then
   type="$match[1]"
   shift match
else
   warn "No \Provides... statement in $loca"
fi

tags=()
[[ -v match ]] && tags=($match)
while (( ${#tags} < 5 )); do tags+=(''); done
[[ -v type ]] || type='<?>'
tags[1]="$type${tags[1]}"

for i in {2..5}; do
   # some tags are set to previously defined commands like \filedate
   if [[ ${tags[$i]} =~ '\\([[:alnum:]@]+)' ]]; then
      $text_extended || extend_text
      m="${match[1]}" # something like filedate
      rep=$(grep -aoP '(?<=\\def\\'$m'\{)[^}]++' <<<"$text" | head -1) ||
      rep=$(grep -aoP '(?<=\\edef\\'$m'\{)[^}]++' <<<"$text" | head -1) ||
      rep=$(grep -aoP '(?<=\\newcommand.\{\\'$m'\}\{)[^}]++' <<<"$text" | head -1)
      # if found, replace \filedate and similar with the definition found for it.
      [[ -n $rep ]] && tags[i]=${tags[i]/\\$m/$rep}
   fi
done

type="${tags[1]}"
[[ ${tags[2]} =~ '\\' ]] || file="${tags[2]}"
[[ ${tags[3]} =~ '\\' ]] || date="${tags[3]}"
[[ ${tags[4]} =~ '\\' ]] || vers="${tags[4]# }"
[[ ${tags[5]} =~ '\\' ]] || info="${tags[5]# }"


# if the version contains no digits, it's probably the first word
# of the description and the version is missing.
if [[ -n $vers ]] && [[ ! $vers =~ [\\0-9] ]]; then
  info="$vers $info"
  vers=''
fi

# sometimes, date+version were defined together in a tex command
if  [[ -z $vers ]] && [[ $date =~ '(.*) (.*)' ]]; then
   date=$match[1]
   vers=$match[2]
fi
 
# clean up info
info=$(sed -E 's/\\(%|&)/\1/g;s/\\string//g' <<<"$info")

# With the mark option, wrong data are marked with a star and,
# when possible, color
if $mark; then
  # type must be Class or ExplClass for ext=cls, Package or ExplPackage for ext=sty, 
  # File or ExplFile for all other extensions
  m=''
  case $type in
     (*Class) 	[[ $ext == cls ]] || m="(Expl)Class";;
     (*Package)	[[ $ext == sty ]] || m="(Expl)Package";;
     (*)	[[ $ext =~ cls\|sty ]] && m="(Expl)File";;
  esac
  # type="$Red$type*$Nor"
  # $flat || type="$type (should be $m)"

  # file must be equal to basename - without extension for Class and
  # Package type, with it for File type:
  m="${loca##*/}"
  [[ $type =~ File ]] || m="${m%.*}"
  if [[ $file != "$m" ]]; then
    file="$Red$file*$Nor"
    $flat || file="$file (should be $m)"
    (( nerror+=16 ))
  fi

  # Check date (date) format
  m=''
  if [[ -z $date ]]; then
    m="absent"
    date='--'
  elif [[ ! $date =~ ^[[:digit:]]{4}[/-][[:digit:]]{2}[/-][[:digit:]]{2}$ ]]; then
    m="wrong format"
  elif [ "$(date -d "$date" +%arg 2>&1 | grep invalid)" != "" ]; then
    # even if it matches, the date must be valid:
    m="invalid date"
  fi
  if [[ -n $m ]]; then
    date="$Red$date*$Nor"
    $flat || date="$date ($m)"
    (( nerror+=8 ))
  fi
  
  # check if version (vers) has usual format. 
  m=''
  if [[ -z $vers ]]; then
    m="absent"
    vers='--'
  elif [[ ! $vers =~ ^v?[0-9.]+[a-z]*$ ]]; then
    m='unusual format'
  fi
  if [[ -n $m ]]; then
    vers="$Red$vers*$Nor"
    $flat || vers="$vers ($m)"
    (( nerror+=4 ))
  fi

  # encoding should be us-ascii of utf-8
  if  [[ ! $enco =~ '^(us-ascii|utf-8)$' ]]; then
    enco="$Red$enco*$Nor"
    (( nerror+=2 ))
  fi

  # info 
  if [[ -z $info ]]; then
    m="absent"
    info="$Red--*$Nor"
    $flat || info="$info ($m)"
    (( nerror++ ))
  fi
fi

if $flat; then
   printf '%05d\t%s\t%s\t%s\t%s\t%s\t%s\t%s' \
      "$(echo "obase=2; $nerror" |bc)" \
      "$file" \
      "$ext" \
      "$date" \
      "$vers" \
      "$enco" \
      "$info" 
   if $verbose; then
      printf "\t%s\n" "$loca"
   else
      echo
   fi   
elif [ ! "$key" ]; then
   for i in file type date vers enco info loca; do
      pr "$i: ${(P)i}"
   done
else
  pr "${(P)key}"
fi
exit $nerror
