wesnoth/data/tools/wmllint
2007-11-10 21:36:47 +00:00

1589 lines
69 KiB
Python
Executable File

#!/usr/bin/env python
#
# wmllint -- up-convert WML and maps between versions.
#
# By Eric S. Raymond April 2007.
#
# All conversion logic for lifting WML and maps from older versions of the
# markup to newer ones should live here. This includes resource path changes
# and renames, also map format conversions.
#
# While the script is at it, it checks for unbalanced tags and warns about them.
#
# Takes any number of directories as arguments. Each directory is converted.
# If no directories are specified, acts on the current directory.
#
# The recommended procedure is this:
# 1. Run it with --dryrun first to see what it will do.
# 2. If the messages look good, run without --dryrun; the old content
# will be left in backup files with a -bak extension.
# 3. Eyeball the changes with the --diff option.
# 4. Use wmlscope, with a directory list including the Wesnoth mainline WML
# as first argument, to check that you have no unresolved references.
# 5. Test the conversion.
# 6. Use either --clean to remove the -bak files or --revert to
# undo the conversion.
#
# This script will barf on 1.2.x maps with custom terrains. Also, if you
# have a single subdirectory that mixes old-style and new-style
# terrain coding it might get confused.
#
# Standalone terrain mask files *must* have a .mask extension on their name
# or they'll have an incorrect usage=map generated into them.
#
# Note: You can shut wmllint up about custom terrains by having a comment
# on the same line that includes the string "wmllint: ignore".
# You can also prevent description insertions with "wmllint: no-icon".
# Finally, you can disable stack-based malformation checks with a comment
# containing "wmllint: validate-off" and re-enable with "wmllint: validate-on".
import sys, os, re, getopt, string, copy, difflib, time
from wesnoth.wmltools import *
from wesnoth.wmliterator import *
filemoves = {
# Older includes all previous to 1.3.1.
"older" : (
# File naming error made repeatedly in NR and elsewhere.
("human-loyalists/human-", "human-loyalists/"),
# These are picked to cover as many as possible of the broken
# references in UMC on the campaign server. Some things we
# don't try to fix include:
# - attack/staff.png may map to one of several staves.
# - magic.wav may map to one of several sounds depending on the unit.
# Some other assumptions that are sound in current UMC as of April 2007
# but theoretically dubious are marked with *.
("../music/defeat.ogg", "defeat.ogg"),
("../music/victory.ogg", "victory.ogg"),
("AMLA_TOUGH_2", "AMLA_TOUGH 2"),
("AMLA_TOUGH_3", "AMLA_TOUGH 3"),
("SOUND_LIST:DAGGER_SWISH", "SOUND_LIST:SWORD_SWISH"),
("arrow-hit.wav", "bow.ogg"),
("arrow-miss.wav", "bow-miss.ogg"),
("attacks/animal-fangs.png","attacks/fangs-animal.png"),
("attacks/crossbow.png", "attacks/human-crossbow.png"), #*
("attacks/dagger.png", "attacks/human-dagger.png"), #*
("attacks/darkstaff.png", "attacks/staff-necromantic.png"),
("attacks/human-fist.png", "attacks/fist-human.png"),
("attacks/human-mace.png", "attacks/mace.png"),
("attacks/human-sabre.png", "attacks/sabre-human.png"),
("attacks/icebolt.png", "attacks/iceball.png"), # Is this right?
("attacks/lightingbolt.png","attacks/lightning.png"),
("attacks/missile.png", "attacks/magic-missile.png"),
("attacks/morning_star.png","attacks/morning-star.png"),
("attacks/plaguestaff.png", "attacks/staff-plague.png"),
("attacks/slam.png", "attacks/slam-drake.png"),
("attacks/staff-magical.png","attacks/staff-magic.png"),
("attacks/sword-paladin.png","attacks/sword-holy.png"),
("attacks/sword.png", "attacks/human-sword.png"), #*
("attacks/sword_holy.png", "attacks/sword-holy.png"),
("attacks/throwing-dagger-human.png", "attacks/dagger-thrown-human.png"),
("bow-hit.ogg", "bow.ogg"),
("bow-hit.wav", "bow.ogg"),
("bowman-attack-sword.png", "bowman-sword-1.png"),
("bowman-attack1.png", "bowman-ranged-1.png"),
("bowman-attack2.png", "bowman-ranged-2.png"),
("creepy.ogg", "underground.ogg"),
("dwarves/warrior.png", "dwarves/fighter.png"),
("eagle.wav", "gryphon-shriek-1.ogg"),
("elfland.ogg", "elf-land.ogg"),
("elvish-fighter.png", "elves-wood/fighter.png"),
("elvish-hero.png", "elves-wood/hero.png"),
("fist.wav", "fist.ogg"),
("flame-miss.ogg", "flame-big-miss.ogg"),
("flame.ogg", "flame-big.ogg"),
("gameplay2.ogg", "gameplay02.ogg"), # Changes in 1.3.2
("goblin-hit2.ogg", "goblin-hit-2.ogg"),
("hatchet-miss-1.ogg", "hatchet-miss.wav"),
("heal.ogg", "heal.wav"),
("hiss-big.ogg", "hiss-big.wav"),
("human-dagger.png", "dagger-human.png"),
("human-male-die.ogg", "human-die-1.ogg"),
("human-male-hit.ogg", "human-hit-1.ogg"),
("human-male-weak-die.ogg", "human-old-die-1.ogg"),
("human-male-weak-hit.ogg", "human-old-hit-1.ogg"),
("human-sword.png", "sword-human.png"),
("items/castle-ruins.png", "scenery/castle-ruins.png"),
("items/fire.png", "scenery/fire1.png"),
("items/fire1.png", "scenery/fire1.png"),
("items/fire2.png", "scenery/fire2.png"),
("items/fire3.png", "scenery/fire3.png"),
("items/fire4.png", "scenery/fire4.png"),
("items/hero-icon.png", "misc/hero-icon.png"),
("items/leanto.png", "scenery/leanto.png"),
("items/lighthouse.png", "scenery/lighthouse.png"),
("items/monolith1.png", "scenery/monolith1.png"),
("items/monolith2.png", "scenery/monolith2.png"),
("items/monolith3.png", "scenery/monolith3.png"),
("items/monolith4.png", "scenery/monolith4.png"),
("items/ring1.png", "items/ring-silver.png"), # Is this right?
("items/ring2.png", "items/ring-gold.png"), # Is this right?
("items/rock1.png", "scenery/rock1.png"),
("items/rock2.png", "scenery/rock2.png"),
("items/rock3.png", "scenery/rock3.png"),
("items/rock4.png", "scenery/rock4.png"),
("items/signpost.png", "scenery/signpost.png"),
("items/slab.png", "scenery/slab-1.png"),
("items/well.png", "scenery/well.png"),
("knife.ogg", "dagger-swish.wav"), # Is this right?
("knife.wav", "dagger-swish.wav"), # Is this right?
("lightning.wav", "lightning.ogg"),
("longbowman-ranged-1.png", "longbowman-bow-attack1.png"),
("longbowman-ranged-2.png", "longbowman-bow-attack2.png"),
("longbowman-ranged-3.png", "longbowman-bow-attack3.png"),
("longbowman-ranged-4.png", "longbowman-bow-attack4.png"),
("misc/chest.png", "items/chest.png"),
("misc/dwarven-doors.png", "scenery/dwarven-doors-closed.png"),
("misc/mine.png", "scenery/mine-abandoned.png"),
("misc/nest-empty.png", "scenery/nest-empty.png"),
("misc/rocks.png", "scenery/rubble.png"),
("misc/snowbits.png", "scenery/snowbits.png"),
("misc/temple.png", "scenery/temple1.png"),
("miss.wav", "miss-1.ogg"),
("orc-die.wav", "orc-die-1.ogg"),
("orc-hit.wav", "orc-hit-1.ogg"),
("ork-die-2.ogg", "orc-die-2.ogg"),
("pistol.wav", "gunshot.wav"),
("spear-miss-1.ogg", "spear-miss.ogg"),
("spearman-attack-south-1.png", "spearman-attack-s-1.png"),
("spearman-attack-south-2.png", "spearman-attack-s-2.png"),
("spearman-attack-south-3.png", "spearman-attack-s-3.png"),
("squishy-miss-1.ogg", "squishy-miss.wav"),
("sword-swish.wav", "sword-1.ogg"),
("sword.wav", "sword-1.ogg"),
("terrain/flag-1.png", "flags/flag-1.png"),
("terrain/flag-2.png", "flags/flag-2.png"),
("terrain/flag-3.png", "flags/flag-3.png"),
("terrain/flag-4.png", "flags/flag-4.png"),
("terrain/rocks.png", "scenery/rock2.png"),
("terrain/signpost.png", "scenery/signpost.png"),
("terrain/village-cave-tile.png","terrain/village/cave-tile.png"),
("terrain/village-dwarven-tile.png","terrain/village/dwarven-tile.png"),
("terrain/village-elven4.png","terrain/village/elven4.png"),
("terrain/village-human-snow.png", "terrain/village/human-snow.png"),
("terrain/village-human.png","terrain/village/human.png"),
("terrain/village-human4.png","terrain/village/human4.png"),
("throwing-dagger-swish.wav","dagger-swish.wav"), # Is this right?
("units/undead/ghost-attack.png", "units/undead/ghost-attack-2.png"),
("units/undead/ghost-attack1.png", "units/undead/ghost-attack-1.png"),
("wolf-attack.wav", "wolf-bite.ogg"),
("wolf-cry.wav", "wolf-die.wav"),
("wose-attack.wav", "wose-attack.ogg"),
(r"wose\.attack.ogg", "wose-attack.ogg"),
),
"1.3.1" : (
# Peasant images moved to a new directory
("human-loyalists/peasant.png", "human-peasants/peasant.png"),
("human-loyalists/peasant-attack.png", "human-peasants/peasant-attack.png"),
("human-loyalists/peasant-attack2.png", "human-peasants/peasant-attack2.png"),
("human-loyalists/peasant-ranged.png", "human-peasants/peasant-ranged.png"),
("human-loyalists/peasant-idle-1.png", "human-peasants/peasant-idle-1.png"),
("human-loyalists/peasant-idle-2.png", "human-peasants/peasant-idle-2.png"),
("human-loyalists/peasant-idle-3.png", "human-peasants/peasant-idle-3.png"),
("human-loyalists/peasant-idle-4.png", "human-peasants/peasant-idle-4.png"),
("human-loyalists/peasant-idle-5.png", "human-peasants/peasant-idle-5.png"),
("human-loyalists/peasant-idle-6.png", "human-peasants/peasant-idle-6.png"),
("human-loyalists/peasant-idle-7.png", "human-peasants/peasant-idle-7.png"),
# All Great Mage attacks were renamed
("great-mage-attack-magic1.png", "great-mage-attack-magic-1.png"),
("great-mage-attack-magic2.png", "great-mage-attack-magic-2.png"),
("great-mage+female-attack-magic1.png", "great-mage+female-attack-magic-1.png"),
("great-mage+female-attack-magic2.png", "great-mage+female-attack-magic-2.png"),
("great-mage-attack-staff1.png", "great-mage-attack-staff-1.png"),
("great-mage-attack-staff2.png", "great-mage-attack-staff-2.png"),
("great-mage+female-attack-staff1.png", "great-mage+female-attack-staff-1.png"),
("great-mage+female-attack-staff2.png", "great-mage+female-attack-staff-2.png"),
# All Arch Mage attacks were renamed
("arch-mage-attack-magic1.png", "arch-mage-attack-magic-1.png"),
("arch-mage-attack-magic2.png", "arch-mage-attack-magic-2.png"),
("arch-mage+female-attack-magic1.png", "arch-mage+female-attack-magic-1.png"),
("arch-mage+female-attack-magic2.png", "arch-mage+female-attack-magic-2.png"),
("arch-mage-attack-staff1.png", "arch-mage-attack-staff-1.png"),
("arch-mage-attack-staff2.png", "arch-mage-attack-staff-2.png"),
("arch-mage+female-attack-staff1.png", "arch-mage+female-attack-staff-1.png"),
("arch-mage+female-attack-staff2.png", "arch-mage+female-attack-staff-2.png"),
# All Red Mage attacks were renamed
("red-mage-attack-magic1.png", "red-mage-attack-magic-1.png"),
("red-mage-attack-magic2.png", "red-mage-attack-magic-2.png"),
("red-mage+female-attack-magic1.png", "red-mage+female-attack-magic-1.png"),
("red-mage+female-attack-magic2.png", "red-mage+female-attack-magic-2.png"),
("red-mage-attack-staff1.png", "red-mage-attack-staff-1.png"),
("red-mage-attack-staff2.png", "red-mage-attack-staff-2.png"),
("red-mage+female-attack-staff1.png", "red-mage+female-attack-staff-1.png"),
("red-mage+female-attack-staff2.png", "red-mage+female-attack-staff-2.png"),
# Timothy Pinkham supplied titles for two of his music files.
# Zhaytee supplied a title for wesnoth-1.ogg
# gameplay03.ogg, and and wesnoth-[25].ogg already had titles.
("gameplay01.ogg", "knolls.ogg"),
("gameplay02.ogg", "wanderer.ogg"),
("gameplay03.ogg", "battle.ogg"),
("wesnoth-1.ogg", "revelation.ogg"),
("wesnoth-2.ogg", "loyalists.ogg"),
("wesnoth-5.ogg", "northerners.ogg"),
# And the holy->arcane change
("type=holy", "type=arcane"),
("holy=", "arcane="),
),
"1.3.2" : (
("misc/item-holywater.png", "items/holywater.png"),
("orc-small-hit.wav", "orc-small-hit-1.ogg"),
),
"1.3.3" : (
("sounds/dragonstick-hit.ogg", "sounds/dragonstick-hit-1.ogg"),
("sounds/dragonstick-miss.ogg", "sounds/dragonstick-miss.wav"),
),
"1.3.4" : (
# This release changed from numeric to string palette IDs
("RC(magenta>1)", "RC(magenta>red)"),
("RC(magenta>2)", "RC(magenta>green)"),
("RC(magenta>3)", "RC(magenta>blue)"),
("RC(magenta>4)", "RC(magenta>purple)"),
("RC(magenta>5)", "RC(magenta>black)"),
("RC(magenta>6)", "RC(magenta>brown)"),
("RC(magenta>7)", "RC(magenta>orange)"),
("RC(magenta>8)", "RC(magenta>white)"),
("RC(magenta>9)", "RC(magenta>teal)"),
("colour=1", "colour=red"),
("colour=2", "colour=green"),
("colour=3", "colour=blue"),
("colour=4", "colour=purple"),
("colour=5", "colour=black"),
("colour=6", "colour=brown"),
("colour=7", "colour=orange"),
("colour=8", "colour=white"),
("colour=9", "colour=teal"),
),
# 1.35 was an aborted release
"1.3.6" : (
("Soul Shooter", "Banebow"),
("Halbardier" , "Halberdier"),
),
"1.3.7" : (),
"1.3.8" : (
# Don't do these yet, there's some controversy.
#("{ABILITY_HEALS}", "{ABILITY_HEALS 4}"),
#("{ABILITY_CURES}", "{ABILITY_CURES_POISON}{ABILITY_HEALS 8}"),
),
"1.3.9" : (
("Outlaw Ranger", "Ranger"),
),
# An empty sentinel value at end is required.
"trunk" : (),
}
# Turn all the filemove string substition pairs into nearly equivalent
# regexp-substitution pairs, forbidding the match from being preceded
# by a dash. This prevents, e.g., "miss.ogg" false-matching on "big-miss.ogg".
for (key, value) in filemoves.items():
filemoves[key] = map(lambda (old, new): (re.compile("(?<!-)"+old), new), value)
# 1.2.x to 1.3.2 terrain conversion
conversion1 = {
" " : "_s",
"&" : "Mm^Xm",
"'" : "Uu^Ii",
"/" : "Ww^Bw/",
"1" : "1 _K",
"2" : "2 _K",
"3" : "3 _K",
"4" : "4 _K",
"5" : "5 _K",
"6" : "6 _K",
"7" : "7 _K",
"8" : "8 _K",
"9" : "9 _K",
"?" : "Gg^Fet",
"A" : "Ha^Vhha",
"B" : "Dd^Vda",
"C" : "Ch",
"D" : "Uu^Vu",
"E" : "Rd",
"F" : "Aa^Fpa",
"G" : "Gs",
"H" : "Ha",
"I" : "Dd",
"J" : "Hd",
"K" : "_K",
"L" : "Gs^Vht",
"M" : "Md",
"N" : "Chr",
"P" : "Dd^Do",
"Q" : "Chw",
"R" : "Rr",
"S" : "Aa",
"T" : "Gs^Ft",
"U" : "Dd^Vdt",
"V" : "Aa^Vha",
"W" : "Xu",
"X" : "Qxu",
"Y" : "Ss^Vhs",
"Z" : "Ww^Vm",
"[" : "Uh",
"\\": "Ww^Bw\\",
"]" : "Uu^Uf",
"a" : "Hh^Vhh",
"b" : "Mm^Vhh",
"c" : "Ww",
"d" : "Ds",
"e" : "Aa^Vea",
"f" : "Gs^Fp",
"g" : "Gg",
"h" : "Hh",
"i" : "Ai",
"k" : "Wwf",
"l" : "Ql",
"m" : "Mm",
"n" : "Ce",
"o" : "Cud",
"p" : "Uu^Vud",
"q" : "Chs",
"r" : "Re",
"s" : "Wo",
"t" : "Gg^Ve",
"u" : "Uu",
"v" : "Gg^Vh",
"w" : "Ss",
"|" : "Ww^Bw|",
"~" : "_f",
}
max_len = max(*map(len, conversion1.values()))
width = max_len+2
def neighborhood(x, y, map):
"Returns list of original location+adjacent locations from a hex map"
odd = (x) % 2
adj = [map[y][x]];
if x > 0:
adj.append(map[y][x-1])
if x < len(map[y])-1:
adj.append(map[y][x+1])
if y > 0:
adj.append(map[y-1][x])
if y < len(map)-1:
adj.append(map[y+1][x])
if x > 0 and y > 0 and not odd:
adj.append(map[y-1][x-1])
if x < len(map[y])-1 and y > 0 and not odd:
adj.append(map[y-1][x+1])
if x > 0 and y < len(map)-1 and odd:
adj.append(map[y+1][x-1])
if x < len(map[y])-1 and y < len(map)-1 and odd:
adj.append(map[y+1][x+1])
return adj
def maptransform1(filename, baseline, inmap, y):
"Transform a map line from 1.2.x to 1.3.x format."
global lock_terrain_coding
# The one truly ugly piece of implementation.
# We're relying here on maps being seen before scenario files.
# We notice whether the maps are oldstyle (single-letter codes)
# or newstyle (multiletter comma-seeparated fields) and retain that
# information to help with ambiguous cases later on. We're also relying
# on terrain coding to be consistent within a single subdirectory.
if len(inmap[y][0]) > 1:
lock_terrain_coding = "newstyle"
else:
format = "%%%d.%ds" % (width, max_len)
for (x, field) in enumerate(inmap[y]):
if field in conversion1:
lock_terrain_coding = "oldstyle"
inmap[y][x] = format % conversion1[field]
else:
raise maptransform_error(filename, baseline+y+1,
"unrecognized map element %s at (%s, %s)" % (`field`, x, y))
# 1.3.1 -> 1.3.2 terrain conversions
conversion2 = {
re.compile(r"(?<!\^)Bww([|/\\])") : "Ww^Bw\\1",
re.compile(r"(?<!\^)Bwo([|/\\])") : "Wo^Bw\\1",
re.compile(r"(?<!\^)Bss([|/\\])") : "Ss^Bw\\1",
re.compile(r"(?<!\^)Dc\b") : "Dd^Dc",
re.compile(r"(?<!\^)Dr\b") : "Dd^Dr",
re.compile(r"(?<!\^)Do\b") : "Dd^Do",
re.compile(r"(?<!\^)Fa\b") : "Aa^Fpa",
re.compile(r"(?<!\^)Fet\b") : "Gg^Fet",
re.compile(r"(?<!\^)Ff\b") : "Gs^Fp",
re.compile(r"(?<!\^)Ft\b") : "Gs^Ft",
re.compile(r"(?<!\^)Rfvs\b") : "Re^Gvs",
re.compile(r"(?<!\^)Uf\b") : "Uu^Uf",
re.compile(r"(?<!\^)Uui\b") : "Uu^Ii",
re.compile(r"(?<!\^)Uhi\b") : "Uh^Ii",
re.compile(r"(?<!\^)Vda\b") : "Dd^Vda",
re.compile(r"(?<!\^)Vdt\b") : "Dd^Vdt",
re.compile(r"(?<!\^)Vea\b") : "Aa^Vea",
re.compile(r"(?<!\^)Veg\b") : "Gg^Ve",
re.compile(r"(?<!\^)Vha\b") : "Aa^Vha",
re.compile(r"(?<!\^)Vhg\b") : "Gg^Vh",
re.compile(r"(?<!\^)Vhh\b") : "Hh^Vhh",
re.compile(r"(?<!\^)Vhha\b") : "Ha^Vhha",
re.compile(r"(?<!\^)Vhm\b") : "Mm^Vhh",
re.compile(r"(?<!\^)Vht\b") : "Gs^Vht",
re.compile(r"(?<!\^)Vu\b") : "Uu^Vu",
re.compile(r"(?<!\^)Vud\b") : "Uu^Vud",
re.compile(r"(?<!\^)Vwm\b") : "Ww^Vm",
re.compile(r"(?<!\^)Vs\b") : "Ss^Vhs",
re.compile(r"(?<!\^)Vsm\b") : "Ss^Vm",
re.compile(r"(?<!\^)Xm\b") : "Mm^Xm",
}
def maptransform2(filename, baseline, inmap, y):
"Convert a map line from 1.3.1 multiletter format to 1.3.2 format."
for x in range(len(inmap[y])):
# General conversions
for (old, new) in conversion2.items():
inmap[y][x] = old.sub(new, inmap[y][x])
# Convert keeps according to adjacent hexes
if "_K" in inmap[y][x]:
adj = map(string.strip, neighborhood(x, y, inmap))
# print "adjacent: %s" % adj
hexcount = {}
# Intentionally skipping 0 as it is original hex
for i in range(1, len(adj)):
if adj[i].startswith("C"): # this is a castle hex
# Magic: extract second character of each adjacent castle,
# which is its base type. Count occurrences of each type.
basetype = adj[i][1]
hexcount[basetype] = hexcount.get(basetype, 0) + 1
maxc = 0;
maxk = "h";
# Note: if two kinds of basetype tie for most instances adjacent,
# which one dominates will be a pseudorandom artifact of
# Python's hash function.
for k in hexcount.keys():
if hexcount[k] > maxc:
maxc = hexcount[k]
maxk = k
#print "Dominated by %s" % maxk
inmap[y][x] = inmap[y][x].replace("_K", "K" + maxk)
# There's only one kind of underground keep at present.
inmap[y][x] = inmap[y][x].replace("Ku", "Kud")
def validate_stack(stack, filename, lineno):
"Check the stack for deprecated WML syntax."
if verbose >= 3:
print '"%s", line %d: %s' % (filename, lineno+1, stack)
if stack:
(tag, attributes) = tagstack[-1]
ancestors = map(lambda x: x[0], tagstack)
#if tag == "sound" and "attack" in ancestors:
# print '"%s", line %d: deprecated [sound] within [attack] tag' % (filename, lineno+1)
def validate_on_pop(tagstack, closer, filename, lineno):
"Validate the stack at the time a new close tag is seen."
(tag, attributes) = tagstack[-1]
ancestors = map(lambda x: x[0], tagstack)
if verbose >= 3:
print '"%s", line %d: closing %s I see %s with %s' % (filename, lineno, closer, tag, attributes)
# Detect a malformation that will cause the game to barf while attempting
# to deserialize an empty unit.
if closer == "side" and "type" not in attributes and ("no_leader" not in attributes or attributes["no_leader"] != "yes") and "multiplayer" not in ancestors:
print '"%s", line %d: [side] without type attribute' % (filename, lineno)
# Syntax transformations
leading_ws = re.compile(r"^\s*")
def leader(s):
"Return a copy of the leading whitespace in the argument."
return leading_ws.match(s).group(0)
def outdent(s):
"Outdent line by one level."
if s.startswith(baseindent):
return s[len(baseindent):]
elif s.endswith("\t"):
return s[:-1] + baseindent
else:
return s
def hack_syntax(filename, lines):
# Syntax transformations go here. This gets called once per WML file;
# the name of the file is passed as filename, text of the file as the
# array of strings in lines. Modify lines in place as needed, and
# set modcount to nonzero when you actually change any.
global versions
modcount = 0
# Ensure that every attack has a translatable description."
for i in range(len(lines)):
if "no-syntax-rewrite" in lines[i]:
break
elif "[attack]" in lines[i]:
j = i;
have_description = False
while '[/attack]' not in lines[j]:
if lines[j].strip().startswith("description"):
have_description = True
j += 1
if not have_description:
j = i
while '[/attack]' not in lines[j]:
fields = lines[j].strip().split('#')
syntactic = fields[0]
comment = ""
if len(fields) > 1:
comment = fields[1]
if syntactic.strip().startswith("name"):
description = syntactic.split("=")[1].strip()
if not description.startswith('"'):
description = '"' + description + '"\n'
# Skip the insertion if this is a dummy declaration
# or one modifying an attack inherited from a base unit.
if "no-icon" not in comment:
new_line = leader(syntactic) + "description=_"+description
if verbose:
print '"%s", line %d: inserting %s' % (filename, i+1, `new_line`)
lines.insert(j+1, new_line)
j += 1
modcount += 1
j += 1
# Ensure that every speaker=narrator block without an image uses
# wesnoth-icon.png as an image.
need_image = False
for i in range(len(lines)):
if "no-syntax-rewrite" in lines[i]:
break
precomment = lines[i].split("#")[0]
if "speaker=narrator" in precomment:
need_image = True
elif precomment.strip().startswith("image"):
need_image = False
elif '[/message]' in precomment:
if need_image:
# This line presumes the code has been through wmlindent
if verbose:
print 'wmllint: "%s", line %d: inserting "image=wesnoth-icon.png"'%(filename, i+1)
lines.insert(i, leader(precomment) + baseindent + "image=wesnoth-icon.png\n")
modcount += 1
need_image = False
# Boucman's transformation of animation syntax
class anim_frame:
def __init__(self, attackline, attackname, lineno, female, variation):
self.attackstart = attackline
self.name = attackname
self.animstart = lineno
self.female = female
self.variation = variation
self.animend = None
self.attackend = None
def __repr__(self):
return `self.__dict__`
in_attack = in_animation = in_female = False
animations = []
attackname = None
attackline = None
for i in range(len(lines)):
if "no-syntax-rewrite" in lines[i]:
break
elif "[female]" in lines[i]:
in_female = True
elif "[/female]" in lines[i]:
in_female = False
elif "[variation]" in lines[i]:
variation_index += 1
in_variation = True
elif "[/variation]" in lines[i]:
in_variation = False
elif "[unit]" in lines[i]:
in_attack = in_animation = in_female = in_variation = False
female_attack_index = -1
variation_index = 0
male_attack_start = len(animations)
elif "[attack]" in lines[i]:
in_attack = True;
attackname = None
attackline = i
if in_female:
female_attack_index += 1
elif "[animation]" in lines[i] and in_attack:
#if verbose:
# print '"%s", line %d: [animation] within [attack]' \
# % (filename, i+1)
# This weird piece of code is because attacks for female
# variants don't have names. Instead, they're supposed
# to pick up the name of the corresponding male attack,
# where correspondence is by order of declaration. The
# male_attack_start variable copes with the possibility
# of multiple units per file.
if attackname == None and in_female:
attackname = animations[male_attack_start + female_attack_index].name
if not attackname:
print '"%s", line %d: cannot deduce attack name'%(filename, i+1)
if in_variation:
variation = variation_index
else:
variation = None
animations.append(anim_frame(attackline, attackname, i, in_female, variation))
in_animation = True
elif "[/animation]" in lines[i] and in_attack:
in_animation = False
if animations and animations[-1].animstart != None and animations[-1].animend == None:
animations[-1].animend = i
else:
print '"%s", line %d: [animation] ending here may be ill-formed'%(filename, i+1)
elif "[/attack]" in lines[i]:
inattack = False;
attackname = None
if animations and (animations[-1].attackstart == None or animations[-1].attackend != None):
print '"%s", line %d: [attack] ending here may be ill-formed'%(filename, i+1)
elif animations:
# This loop is needed because a single attack tag may
# enclose both hit and miss animations.
j = len(animations)-1
while True:
animations[j].attackend = i
j -= 1
if j < 0 or animations[j].attackend != None:
break
# Only pick up the *first* name field in an attack block;
# by convention, it will be right after the opening [attack] tag
elif in_attack and not in_animation and not attackname:
#print filename + ":" + `i+1` + ";" + `lines[i]`
fields = lines[i].strip().split('#')
syntactic = fields[0]
comment = ""
if len(fields) > 1:
comment = fields[1]
if syntactic.strip().startswith("name"):
attackname = syntactic.split("=")[1].strip()
boucmanized = False
# All animation ranges have been gathered, We have a list of objects
# containing the attack information. Reverse it, because we're
# going to process them back to front to avoid invalidating the
# already-collected line numbers. Then pull out the animation
# WML and stash it in the frame objects.
animations.reverse()
for aframe in animations:
if verbose:
print '"%s", line %d: lifting animation block at %d:%d for %s attack (%d:%d)' % (filename, aframe.animstart+1, aframe.animstart+1, aframe.animend+1, aframe.name, aframe.attackstart+1, aframe.attackend+1)
# Make a copy of the animation block, change its enclosing tags,
# outdent it, and add the needed filter clause.
animation = lines[aframe.animstart:aframe.animend+1]
animation[0] = animation[0].replace("[animation]", "[attack_anim]")
animation[-1] = animation[-1].replace("[/animation]","[/attack_anim]")
for i in range(len(animation)):
animation[i] = outdent(animation[i])
indent = leader(animation[1])
animation.insert(1, indent + "[/attack_filter]\n")
animation.insert(1, indent + baseindent + "name="+aframe.name+"\n")
animation.insert(1, indent + "[attack_filter]\n")
# Save it and delete it from its original location
aframe.wml = "".join(animation)
lines = lines[:aframe.animstart] + lines[aframe.animend+1:]
modcount += 1
boucmanized = True
# Insert non-variation attacks where they belong
female_attacks = filter(lambda a: a.female and a.variation == None, animations)
female_attacks.reverse()
if female_attacks:
female_end = -1
for i in range(len(lines)):
if lines[i].endswith("[/female]\n"):
female_end = i
break
assert female_end != -1
female_wml = "".join(map(lambda x: x.wml, female_attacks))
lines = lines[:female_end] + [female_wml] + lines[female_end:]
male_attacks = filter(lambda a: not a.female and a.variation == None, animations)
male_attacks.reverse()
if male_attacks:
male_end = -1
for i in range(len(lines)):
# Male attacks go either before the [female] tag or just
# before the closing [/unit]
if lines[i].endswith("[/unit]\n") or lines[i].endswith("[female]\n"):
male_end = i
break
assert male_end != -1
male_wml = "".join(map(lambda x: x.wml, male_attacks))
lines = lines[:male_end] + [male_wml] + lines[male_end:]
# Now insert variation attacks where they belong.
for animation in animations:
if animation.variation != None:
vcount = 0
for j in range(len(lines)):
if "[/variation]" in lines[j]:
vcount += 1
if vcount == animation.variation:
break
lines = lines[:j] + [animation.wml] + lines[j:]
# Garbage-collect any empty [attack] scopes left behind;
# this is likely to happen with female-variant units.
nullattack = True
while nullattack:
nullattack = False
for i in range(len(lines)-1):
if lines[i].strip() == "[attack]" and lines[i+1].strip() == "[/attack]":
nullattack = True
break
if nullattack:
lines = lines[:i] + lines[i+2:]
# Lift new_attack animation blocks within [effect] tags
# Note: This assumes that the animation WML goes last in the [effect] WML
# with nothing after it, and will fail if that is not true.
in_effect = False
attackname = None
converting = False
for i in range(len(lines)):
if "no-syntax-rewrite" in lines[i]:
break
elif "[effect]" in lines[i]:
in_effect = True
elif "apply_to=new_attack" in lines[i]:
converting = True
elif "[/effect]" in lines[i]:
converting = in_effect = False
elif in_effect and not attackname:
#print filename + ":" + `i+1` + ";" + `lines[i]`
fields = lines[i].strip().split('#')
syntactic = fields[0]
comment = ""
if len(fields) > 1:
comment = fields[1]
if syntactic.strip().startswith("name"):
attackname = syntactic.split("=")[1].strip()
elif converting and "[animation]" in lines[i]:
print '"%s", line %d: converting [animation] in [effect] '%(filename, i+1)
ws = leader(lines[i])
outer = outdent(ws)
assert attackname != None
before = outer + "[/effect]\n" \
+ outer + "[effect]\n" \
+ ws + "apply_to=new_animation\n"
after = ws + baseindent + "[attack_filter]\n" \
+ ws + baseindent*2 + "name=" + attackname +" \n" \
+ ws + baseindent + "[/attack_filter]\n"
lines[i] = before \
+ lines[i].replace("animation", "attack_anim") \
+ after
modcount += 1
elif converting and "[/animation]" in lines[i]:
lines[i] = lines[i].replace("animation", "attack_anim")
# Lift [frame] declarations directly within attacks
# Note: This assumes that the frame sequence goes last in the [attack] WML
# with nothing after it, and will fail if that is not true.
in_attack = False
attackname = None
soundpath = None
in_sound = False
converting = 0
for i in range(len(lines)):
if "no-syntax-rewrite" in lines[i]:
break
elif "[attack]" in lines[i]:
in_attack = True
elif "[/attack]" in lines[i]:
if converting:
lines[i] = lines[i].replace("/attack", "/attack_anim")
converting = 0
in_attack = False
elif ("[frame]" in lines[i] or "[missile_frame]" in lines[i]) and in_attack and converting == 0:
assert attackname != None
print '"%s", line %d: converting frame in [attack] '%(filename, i+1)
ws = leader(lines[i])
outer = outdent(ws)
insertion = outer + "[/attack]\n" \
+ outer + "[attack_anim]\n" \
+ ws + "[attack_filter]\n" \
+ ws + baseindent + "name=" + attackname +" \n" \
+ ws + "[/attack_filter]\n"
lines[i] = insertion + lines[i]
if soundpath:
lines[i] += ws + baseindent + "sound=" + soundpath + "\n"
converting += 1
modcount += 1
elif in_attack:
fields = lines[i].strip().split('#')
syntactic = fields[0]
comment = ""
if len(fields) > 1:
comment = fields[1]
if not attackname and syntactic.strip().startswith("name"):
attackname = syntactic.split("=")[1].strip()
if not soundpath and syntactic.strip().startswith("sound"):
soundpath = syntactic.split("=")[1].strip()
# Ignore sound tags, and their contents, within [attack]
if "[sound]" in lines[i]:
print '"%s", line %d: [sound] within [attack] discarded (path will be saved)' % (filename, i+1)
in_sound = True
modcount += 1
if "[/sound]" in lines[i]:
lines[i] = ""
in_sound = False
if in_sound:
lines[i] = ""
# Upconvert ancient ability declarations from 1.x
level = None
abilities = []
specials = []
lastability = None
lastspecial = None
for i in range(len(lines)):
if "no-syntax-rewrite" in lines[i]:
break
if "[unit]" in lines[i]:
abilities = []
if "[attack]" in lines[i]:
specials = []
elif "[/attack]" in lines[i]:
if specials:
if verbose:
print "Lifting obsolete specials:", " ".join(specials)
ws = leader(lines[i])
insertion = ws + baseindent + "[specials]\n"
for special in specials:
if special.startswith("plague("):
insertion += ws + baseindent*2 + "{WEAPON_SPECIAL_PLAGUE_TYPE " + special[7:-1] + "}\n"
elif special in ("backstab", "berserk", "charge", "drain",
"firstrtrike", "magical", "plague",
"poison", "slow", "stone", "swarm",):
insertion += ws + baseindent*2 + "{WEAPON_SPECIAL_" + special.upper() + "}\n"
else:
print "Don't know how to convert '%s'" % special
insertion += ws + baseindent + "[/specials]\n"
lines[lastspecial] = insertion
modcount += 1
elif "[/unit]" in lines[i]:
if abilities:
if verbose:
print "Lifting obsolete abilities:", " ".join(abilities)
ws = leader(lines[i])
insertion = ws + baseindent + "[abilities]\n"
for ability in abilities:
if ability == "leadership":
if level is None:
print "warning: can't convert ancient leadership ability"
else:
insertion += ws + baseindent*2 + "{ABILITY_LEADERSHIP_LEVEL_"+level+"}\n"
elif ability in ("cures", "heals", "regenerates",
"skirmisher", "illuminates",
"teleport", "ambush",):
insertion += ws + baseindent*2 + "{ABILITY_" + ability.upper() + "}\n"
else:
print "Don't know how to convert '%s'" % ability
insertion += ws + baseindent + "[/abilities]\n"
lines[lastability] = insertion
modcount += 1
elif lines[i].count("=") == 1:
(tag, value) = lines[i].strip().split("=")
if tag == "level":
level = value
if tag == "ability":
abilities.append(value)
lastability = i
lines[i] = ""
if tag == "special":
specials.append(value)
lastspecial = i
lines[i] = ""
# Upconvert old radius usage
if "1.3.7" in versions and "older" not in versions:
radius_pos = wmlfind("radius=", WmlIterator(lines, filename))
while radius_pos is not None:
scopeIter = radius_pos.iterScope()
startline = scopeIter.lineno + 1
wspace = radius_pos.text
wspace = wspace[:len(wspace)-len(wspace.lstrip())]
radius_danger = False
to_indent = []
no_indent = []
insideElem = 0
for i in scopeIter:
elem = i.element
if elem in ("[and]", "[or]", "[not]"):
radius_danger = True
no_indent.extend(txt+'\n' for txt in i.text.splitlines())
insideElem += 1
elif insideElem:
if elem in ("[/and]", "[/or]", "[/not]"):
insideElem -= 1
no_indent.extend(txt+'\n' for txt in i.text.splitlines())
elif elem in ("variable=", "side=", "count=", "adjacent="):
no_indent.extend(txt+'\n' for txt in i.text.splitlines())
else:
to_add = [txt+'\n' for txt in i.text.splitlines()]
to_add[0] = baseindent + to_add[0]
to_indent.extend(to_add)
if radius_danger:
lines = lines[:startline] + [wspace + "[and]\n"] + to_indent +[
wspace + "[/and]\n"] + no_indent + lines[scopeIter.lineno:]
radius_pos.lines = lines
modcount += 1
#backup to rescan
radius_pos.seek(startline-1)
#pass the inserted content
radius_pos.seek(startline+len(to_indent)+1)
radius_pos = wmlfind("radius=", radius_pos)
# Boucmanize death animations
if future:
in_death = None
frame_commented = in_death_commented = False
frame_start = frame_end = None
image = None
for i in range(len(lines)):
if "no-syntax-rewrite" in lines[i]:
break
elif "[death]" in lines[i]:
in_death = i
in_death_commented = lines[i].strip().startswith("#")
elif "[/death]" in lines[i]:
if frame_start is None:
print >>sys.stderr, '"%s", %d: [death] with no frames' % (filename, i)
continue
# Find the image tag
for inside in range(frame_start, frame_end):
if "image=" in lines[inside]:
image = lines[inside].strip().split("=")[1]
break
else:
print >>sys.stderr,'"%s", line %d: no image in last frame'\
% (filename, i)
continue
# Modify the death wrapper
lines[i] = lines[i].replace("death", "animation")
inner = leader(lines[in_death])+baseindent
if in_death_commented:
inner = "#" + inner
lines[in_death] = lines[in_death].replace("death", "animation") \
+ inner + "apply_to=death" + "\n"
# Add a new last frame to the death animation
outer = leader(lines[frame_start])
if frame_commented:
outer = "#" + outer
inner = outer + baseindent
if frame_commented:
inner = "#" + inner
insertion = outer + "[frame]\n" + \
inner + "duration=600\n" + \
inner + "alpha=1~0\n" + \
inner + "image=" + image + "\n" + \
outer + "[/frame]\n"
lines[i] = insertion + lines[i]
in_death = frame_start = frame_end = None
frame_commented = in_death_commented = False
modcount += 1
elif in_death and "[frame]" in lines[i]:
frame_start = i
frame_commented = lines[i].strip().startswith("#")
elif in_death and "[/frame]" in lines[i]:
frame_end = i
# Check for duplicated attack names -- may be a result of a naive
# boucman conversion.
if boucmanized:
name_pos = wmlfind("name=", WmlIterator(lines, filename))
duplist = {}
while name_pos is not None:
key = lines[name_pos.lineno].strip()
context = map(lambda x: x.element, name_pos.scopes)
if '[attack]' in context:
if key not in duplist:
duplist[key] = []
duplist[key].append(name_pos.lineno)
# Go to next
name_pos = wmlfind("name=", name_pos)
for (key, linenos) in duplist.items():
if len(linenos) > 1:
print >>sys.stderr, 'warning: duplicated attack %s at:' % key
for dup in linenos:
print '"%s", %d: %s' % (filename, dup, key)
# Lift obsolete image_short and image_long tags
expanded = """\
[attack_anim]
apply_to=attack
start_time=-150
[frame]
duration=300
image=%s
[/frame]
[attack_filter]
range=%s
[/attack_filter]
[/attack_anim]\
"""
for i in range(len(lines)):
if "no-syntax-rewrite" in lines[i]:
break
m = re.search(r"(\s+)image_short=(.*)", lines[i])
if m:
image_block = expanded.replace("\n", "\n" + m.group(1)) + "\n"
lines[i] = m.group(1) + image_block % (m.group(2), "melee")
modcount += 1
m = re.search(r"(\s+)image_long=(.*)", lines[i])
if m:
image_block = expanded.replace("\n", "\n" + m.group(1)) + "\n"
lines[i] = m.group(1) + image_block % (m.group(2), "ranged")
modcount += 1
# More syntax transformations would go here.
return (lines, modcount)
# Generic machinery starts here
def is_map(filename):
"Is this file a map in either old or new style?"
if isresource(filename) or '{' in filename or '}' in filename:
return False
if "map" in os.path.dirname(filename) or filename.endswith(".map"):
return True
try:
fp = open(filename)
lines = fp.readlines()
fp.close()
has_map_content = False
for i in range(len(lines)):
if lines[i].endswith("\n"):
lines[i] = lines[i][:-1]
if lines[i].endswith("\r"):
lines[i] = lines[i][:-1]
w = len(lines[0])
for line in lines:
if len(line) != w:
break
else:
has_map_content = len(lines) > 1
except OSError:
has_map_content = False
except IndexError:
has_map_content = False
return has_map_content
class maptransform_error:
"Error object to be thrown by maptransform."
def __init__(self, infile, inline, type):
self.infile = infile
self.inline = inline
self.type = type
def __repr__(self):
return '"%s", line %d: %s' % (self.infile, self.inline, self.type)
tagstack = [] # For tracking tag nesting
def outermap(func, inmap):
"Apply a transformation based on neighborhood to the outermost ring."
# Top and bottom rows
for i in range(len(inmap[0])):
inmap[0][i] = func(inmap[0][i])
inmap[len(inmap)-1][i] = func(inmap[len(inmap)-1][i])
# Leftmost and rightmost columns excluding top and bottom rows
for i in range(1, len(inmap)-1):
inmap[i][0] = func(inmap[i][0])
inmap[i][len(inmap[0])-1] = func(inmap[i][len(inmap[0])-1])
def translator(filename, mapxforms, textxform):
"Apply mapxform to map lines and textxform to non-map lines."
global tagstack
modified = False
mfile = []
map_only = not filename.endswith(".cfg")
terminator = "\n"
for line in open(filename):
if line.endswith("\n"):
line = line[:-1]
if line.endswith("\r"):
line = line[:-1]
if not stripcr:
terminator = '\r\n'
mfile.append(line)
if "map_data" in line:
map_only = False
lineno = baseline = 0
cont = False
validate = True
newdata = []
refname = None
while mfile:
if not map_only:
line = mfile.pop(0)
if verbose >= 3:
sys.stdout.write(line + terminator)
lineno += 1
# Check for one certain error condition
if line.count("{") and line.count("}"):
refname = line[line.find("{"):line.rfind("}")]
# Ignore all-caps macro arguments.
if refname == refname.upper():
refname = None
if 'mask=' in line and refname and not refname.endswith(".mask"):
print >>sys.stderr, \
'"%s", line %d: fatal error, mask file without .mask extension (%s)' \
% (filename, lineno+1, refname)
sys.exit(1)
# Exclude map_data= lines that are just 1 line without
# continuation, or which contain {}. The former are
# pathological and the parse won't handle them, the latter
# refer to map files which will be checked separately.
if map_only or (("map_data=" in line or "mask=" in line)
and line.count('"') in (1, 2)
and line.count("{") == 0
and line.count("}") == 0):
outmap = []
add_border = True
add_usage = True
have_header = have_delimiter = False
maskwarn = False
maptype = None
if map_only:
if filename.endswith(".mask"):
maptype = "mask"
else:
maptype = "map"
else:
if "map_data" in line:
maptype = "map"
elif "mask" in line:
maptype = "mask"
baseline = 0
cont = True
if verbose >= 3:
print "*** Entering map mode."
if not map_only:
fields = line.split('"')
if fields[1].strip():
mfile.insert(0, fields[1])
if len(fields) == 3:
mfile.insert(1, '"')
# Gather the map header (if any) and data lines
while cont and mfile:
line = mfile.pop(0)
if verbose >= 3:
sys.stdout.write(line + terminator)
lineno += 1
# This code supports ignoring comments and header lines
if len(line) == 0 or line[0] == '#' or '=' in line:
if '=' in line:
have_header = True
if 'border_size' in line:
add_border = False
if "usage" in line:
add_usage = False
usage = line.split("=")[1].strip()
if usage == 'mask':
add_border = False
if filename.endswith(".map"):
print "warning: usage=mask in file with .map extension"
elif usage == 'map':
if filename.endswith(".mask"):
print "warning: usage=map in file with .mask extension"
if len(line) == 0:
have_delimiter = True
newdata.append(line + terminator)
continue
if '"' in line:
cont = False
if verbose >= 3:
print "*** Exiting map mode."
line = line.split('"')[0]
if line:
if ',' in line:
fields = line.split(",")
else:
fields = map(lambda x: x, line)
outmap.append(fields)
if not maskwarn and maptype == 'map' and "_s" in line:
print >>sys.stderr, \
'"%s", line %d: warning, fog in map file' \
% (filename, lineno+1)
maskwarn = True
# Checking the outmap length here is a bit of a crock;
# the one-line map we don't want to mess with is in the
# NO_MAP macro.
if len(outmap) == 1:
add_border = add_usage = False
# Deduce the map type
if not map_only:
if maptype == "map":
newdata.append("map_data=\"" + terminator)
elif maptype == "mask":
newdata.append("mask=\"" + terminator)
original = copy.deepcopy(outmap)
for transform in mapxforms:
for y in range(len(outmap)):
transform(filename, baseline, outmap, y)
if maptype == "mask":
add_border = False
if add_border:
if verbose:
print "adding border..."
newdata.append("border_size=1" + terminator)
have_header = True
# Start by duplicating the current outermost ring
outmap = [outmap[0]] + outmap + [outmap[-1]]
for i in range(len(outmap)):
outmap[i] = [outmap[i][0]] + outmap[i] + [outmap[i][-1]]
# Strip villages out of the edges
outermap(lambda n: re.sub(r"\^V[a-z]+", "", n), outmap)
# Strip keeps out of the edges
outermap(lambda n: re.sub(r"K([a-z]+)", r"C\1", n), outmap)
# Strip the starting positions out of the edges
outermap(lambda n: re.sub(r"[1-9] ", r"", n), outmap)
# Turn big trees on the edges to ordinary forest hexes
outermap(lambda n: n.replace(r"Gg^Fet", r"Gs^Fp"), outmap)
modified = True
if add_usage:
newdata.append("usage=" + maptype + terminator)
have_header = True
modified = True
if have_header and not have_delimiter:
newdata.append(terminator)
modified = True
for y in range(len(outmap)):
newdata.append(",".join(outmap[y]) + terminator)
if not modified and original[y] != outmap[y]:
modified = True
# All lines of the map are processed, add the appropriate trailer
if not map_only:
newdata.append("\"" + terminator)
elif "map_data=" in line and (line.count("{") or line.count("}")):
newline = line
refre = re.compile(r"\{@?([^A-Z].*)\}").search(line)
if refre:
mapfile = refre.group(1)
if not mapfile.endswith(".map") and is_map(mapfile):
newline = newline.replace(mapfile, mapfile + ".map")
newdata.append(newline + terminator)
if newline != line:
modified = True
if verbose > 0:
print >>sys.stderr, 'wmllint: "%s", line %d: %s -> %s.' % (filename, lineno, line, newline)
elif "map_data=" in line and line.count('"') > 1:
print >>sys.stderr, 'wmllint: "%s", line %d: one-line map.' % (filename, lineno)
newdata.append(line + terminator)
else:
# Handle text (non-map) lines
newline = textxform(filename, lineno, line)
newdata.append(newline + terminator)
if newline != line:
modified = True
# Now do warnings based on the state of the tag stack
fields = newline.split("#")
trimmed = fields[0]
destringed = re.sub('"[^"]*"', '', trimmed) # Ignore string literals
comment = ""
if len(fields) > 1:
comment = fields[1]
for instance in re.finditer(r"\[\/?\+?([a-z][a-z_]*[a-z])\]", destringed):
tag = instance.group(1)
attributes = []
closer = instance.group(0)[1] == '/'
if not closer:
tagstack.append((tag, {}))
else:
if len(tagstack) == 0:
print '"%s", line %d: closer [/%s] with tag stack empty.' % (filename, lineno+1, tag)
elif tagstack[-1][0] != tag:
print '"%s", line %d: unbalanced [%s] closed with [/%s].' % (filename, lineno+1, tagstack[-1][0], tag)
else:
if validate:
validate_on_pop(tagstack, tag, filename, lineno)
tagstack.pop()
if tagstack:
for instance in re.finditer(r'([a-z][a-z_]*[a-z])\s*=(\w+|"[^"]*")', trimmed):
attribute = instance.group(1)
value = instance.group(2)
tagstack[-1][1][attribute] = value
if validate:
validate_stack(tagstack, filename, lineno)
if "wmllint: validate-on" in comment:
validate = True
if "wmllint: validate-off" in comment:
validate = False
# It's an error if the tag stack is nonempty at the end of any file:
if tagstack:
print >>sys.stderr, '"%s", line %d: tag stack nonempty (%s) at end of file.' % (filename, lineno, tagstack)
tagstack = []
# OK, now perform WML rewrites
(newdata, hacked) = hack_syntax(filename, newdata)
# Run everything together
filetext = "".join(newdata)
# WML syntax changed in 1.3.5. The transformation cannot
# conveniently be done line-by-line.
transformed = re.sub(r"(if]|while])\s*\[or]([\w\W]*?)\[/or]\s*",
r"\1\2", filetext);
# Return None if the transformation functions made no changes.
if modified or hacked or transformed != filetext:
return transformed
else:
return None
def interesting(fn):
"Is a file interesting for conversion purposes?"
if fn.endswith("~") or fn[-4:] in (".tgz", ".png", ".jpg", "-bak"):
return False
return fn.endswith(".cfg") or is_map(fn)
def allcfgfiles(dir):
"Get the names of all interesting files under dir."
datafiles = []
if not os.path.isdir(dir):
if interesting(dir):
if not os.path.exists(dir):
sys.stderr.write("wmllint: %s does not exist\n" % dir)
else:
datafiles.append(dir)
else:
for root, dirs, files in os.walk(dir):
if vcdir in dirs:
dirs.remove(vcdir)
for name in files:
if interesting(os.path.join(root, name)):
datafiles.append(os.path.join(root, name))
datafiles.sort() # So diffs for same campaigns will cluster in reports
return map(os.path.normpath, datafiles)
def help():
sys.stderr.write("""\
Usage: wmllint [options] [dir]
Convert Battle of Wesnoth WML from older versions to newer ones.
Takes any number of directories as arguments. Each directory is converted.
If no directories are specified, acts on the current directory.
Options may be any of these:
-h, --help Emit this help message and quit.
-d, --dryrun List changes but don't perform them.
-o, --oldversion Specify version to begin with.
-v, --verbose -v lists changes.
-v -v names each file before it's processed.
-v -v -v shows verbose parse details.
-c, --clean Clean up -bak files.
-D, --diff Display diffs between converted and unconverted files.
-r, --revert Revert the conversion from the -bak files.
-s, --stripcr Convert DOS-style CR/LF to Unix-style LF.
--future Enable experimental WML conversions.
""")
if __name__ == '__main__':
global versions
try:
(options, arguments) = getopt.getopt(sys.argv[1:], "cdfDho:rsv", [
"help",
"oldversion=",
"dryrun",
"future",
"verbose",
"clean",
"revert",
"diffs",
"stripcr",
])
except getopt.GetoptError:
help()
sys.exit(1)
oldversion = 'older'
dryrun = False
future = False
verbose = 0
clean = False
diffs = False
revert = False
stripcr = False
for (switch, val) in options:
if switch in ('-h', '--help'):
help()
sys.exit(0)
elif switch in ('-o', '--oldversion'):
oldversion = val
elif switch in ('-f', '--future'):
future = True
elif switch in ('-v', '--verbose'):
verbose += 1
elif switch in ('-d', '--dryrun'):
dryrun = True
verbose = max(1, verbose)
elif switch in ('-c', '--clean'):
clean = True
elif switch in ('-d', '--diffs'):
diffs = True
elif switch in ('-r', '--revert'):
revert = True
elif switch in ('-s', '--stripcr'):
stripcr = True
if clean and revert:
sys.stderr.write("wmllint: can't do clean and revert together.\n")
sys.exit(1)
# Compute the series of version upgrades to perform, and describe it.
versions = filemoves.keys()
versions.sort()
# Relies on 'older' sorting before trunk
versions = [versions[-2]] + versions[:-2] + [versions[-1]] # Move 'older' to front
if oldversion in versions:
versions = versions[versions.index(oldversion):]
else:
print >>sys.stderr, "wmllint: unrecognized version."
sys.exit(1)
if not dryrun and not clean and not revert and len(versions) > 1:
explain = "Upgrades for:"
for i in range(len(versions)-1):
explain += " %s -> %s," % (versions[i], versions[i+1])
sys.stdout.write(explain[:-1] + ".\n")
fileconversions = map(lambda x: filemoves[x], versions[:-1])
def hasdigit(str):
for c in str:
if c in "0123456789":
return True
return False
def parse_attribute(str):
"Parse a WML key-value pair from a line."
if '=' not in str:
return None
m = re.match(r"(^\s*[a-z0-9_]+\s*=\s*)(\S+)(\s*#?.*\s*)", str)
if not m:
return None
# Four fields: stripped key, part of line before value,
# value, trailing whitespace and comments
return (m.group(1).replace("=", "").strip(),) + m.groups()
def texttransform(filename, lineno, line):
"Resource-name transformation on text lines."
transformed = line
# First, do resource-file moves
for step in fileconversions:
for (old, new) in step:
transformed = old.sub(new, transformed)
# Handle terrain_liked=, terrain=, valid_terrain=, letter=
spaceless = transformed.replace(" ", "").replace("\t", "")
if spaceless and spaceless[0] != "#" and ("terrain_liked=" in spaceless or "terrain=" in spaceless or 'letter=' in spaceless) and "wmllint:ignore" not in spaceless:
(key, pre, value, post) = parse_attribute(transformed)
# We have to cope with the following cases...
# Old style:
# terrain_liked=ghM
# terrain_liked=BEITU
# valid_terrain=gfh
# terrain=AaBbDeLptUVvYZ
# terrain=r
# terrain={LETTERS}
# terrain=""
# terrain=s,c,w,k
# New style:
# terrain=Mm
# terrain=Gs^Fp
# terrain=Hh, Gg^Vh, Mm
# The sticky part is that, while it never happens in the current
# corpus, terrain=Mm (capital letter followed by small) could be
# interpreted either way.
#
# There are some unambiguous tests:
oldstyle = (len(value) == 1 or len(value) > 6) and not ',' in value
newstyle = len(value) > 1 \
and value[0].isupper() and value[1].islower() \
and (',' in value \
or len(value) == 2 \
or (len(value) >= 3 and value[2] == "^"))
# See maptransform1() for explanation of this ugly hack.
oldstyle = oldstyle or lock_terrain_coding == "oldstyle"
newstyle = newstyle or lock_terrain_coding == "newstyle"
# Maybe we lose...
if not oldstyle and not newstyle:
print '"%s", line %d: leaving ambiguous terrain value %s alone.' \
% (filename, lineno, value)
else:
if oldstyle:
# 1.2.x to 1.3.2 conversions
newterrains = ""
inmacro = False
for c in value:
if not inmacro:
if c == '{':
inmacro = True
newterrains += c
elif c == ',':
pass
elif c.isspace():
newterrains += c
elif c in conversion1:
newterrains += conversion1[c] + ","
else:
print "%s, line %d: custom terrain %s ignored." \
% (filename, lineno+1, c)
else: # inmacro == True
if c == '}':
inmacro = False
newterrains += c
if newterrains.endswith(","):
newterrains = newterrains[:-1]
transformed = pre + newterrains + post
if newstyle:
if len(value) == 2:
# 1.3.1 to 1.3.2 conversion
for (old, new) in conversion2.items():
transformed = old.sub(new, transformed)
# Check for things marked translated that aren't strings
if "_" in transformed and not "wmllint: ignore" in transformed:
m = re.search(r'[=(]\s*_\s+("?)', transformed)
if m and not m.group(1):
msg = '"%s", line %d: translatability mark before non-string' % \
(filename, lineno)
print >>sys.stderr, msg
# Report the changes
if verbose > 0 and transformed != line:
msg = "%s, line %d: %s -> %s" % \
(filename, lineno, line.strip(), transformed.strip())
print msg
return transformed
if "1.3.1" in versions and "older" not in versions:
maptransforms = [maptransform2]
else:
maptransforms = [maptransform1, maptransform2]
if not arguments:
arguments = ["."]
for dir in arguments:
ofp = None
if "older" in versions:
lock_terrain_coding = None
else:
lock_terrain_coding = "newstyle"
for fn in allcfgfiles(dir):
if verbose >= 2:
print fn + ":"
backup = fn + "-bak"
if clean or revert:
# Do housekeeping
if os.path.exists(backup):
if clean:
print "wmllint: removing %s" % backup
if not dryrun:
os.remove(backup)
elif revert:
print "wmllint: reverting %s" % backup
if not dryrun:
os.rename(backup, fn)
elif diffs:
# Display diffs
if os.path.exists(backup):
fromdate = time.ctime(os.stat(backup).st_mtime)
todate = time.ctime(os.stat(fn).st_mtime)
fromlines = open(backup, 'U').readlines()
tolines = open(fn, 'U').readlines()
diff = difflib.unified_diff(fromlines, tolines,
backup, fn, fromdate, todate, n=3)
sys.stdout.writelines(diff)
else:
# Do file conversions
try:
changed = translator(fn, maptransforms, texttransform)
if changed:
print "wmllint: converting", fn
if not dryrun:
os.rename(fn, backup)
ofp = open(fn, "w")
ofp.write(changed)
ofp.close()
except maptransform_error, e:
sys.stderr.write("wmllint: " + `e` + "\n")
except:
sys.stderr.write("wmllint: internal error on %s\n" % fn)
(exc_type, exc_value, exc_traceback) = sys.exc_info()
raise exc_type, exc_value, exc_traceback
# Time for map file renames
# FIXME: We should make some effort to rename mask files.
if not revert and not diffs and not fn.endswith(".map") and not fn.endswith(".mask") and is_map(fn):
mover = vcmove(fn, fn + ".map")
print mover
if not dryrun:
os.system(mover)
# wmllint ends here