انتقل إلى المحتوى

وحدة:ar-verb-form

من ويكاموس، القاموس الحر

-- Module:ar-verb-form
-- By: Wael (User:ForzaGreen)
--
-- Renders a conjugated-form (non-lemma) page for an Arabic verb, e.g. أَكَلْتُ.
--
-- The page passes only the verb's root and morphological pattern. This module
-- re-conjugates the verb through Module:ar-verb and works out for itself which
-- slots the page title represents, so the wikitext never states the grammar and
-- therefore can never state it wrongly. When Module:ar-verb is corrected, every
-- form page re-renders; a title that no longer matches any slot lands in
-- [[تصنيف:صفحات صيغ أفعال بلا مطابقة]] instead of silently showing stale grammar.
--
--    {{صيغة فعل|جذر=أكل|وزن=فعَل يفعُل}}
--
-- A form belonging to several verbs repeats the pair: |جذر2=|وزن2=
--
-- Parameters:
--    جذر, وزن   required
--    جذر2 …     up to MAX_VERBS additional verbs
--    صفحة       override the page title (documentation and tests)
--    قسم        render one section instead of the whole page, so that a
--               contributor can split a page apart and write between the
--               sections: صيغة فعل | النطق | التصريفات | الرسم. Absent = الكل.

local p = {}

local ar_verb = require("Module:ar-verb")
local u = mw.ustring.char

local MAX_VERBS = 6

-------------------------------------------------------------------------------
-- Labels
-------------------------------------------------------------------------------

-- Wording follows the conjugation-table headers in Module:ar-verb (make_table)
-- and the pronoun labels of its simplified ar.wiktionary table (make_table_ar).
local TENSE = {
	past = "الماضي",
	ind  = "المضارع المرفوع",
	sub  = "المضارع المنصوب",
	juss = "المضارع المجزوم",
	imp  = "الأمر",
}

local VOICE = {
	act  = "المبني للمعلوم",
	pass = "المبني للمجهول",
}

local PERSON = {["1"] = "المتكلم", ["2"] = "المخاطب", ["3"] = "الغائب"}
local NUMBER = {s = "المفرد", d = "المثنى", p = "الجمع"}
local GENDER = {m = "المذكر", f = "المؤنث"}

-- The same vocabulary without the article, for category names. ar.wiktionary
-- already names these categories this way — تصنيف:فعل مبني للمعلوم ماضي مفرد مذكر متكلم
-- has 133 pages — so the composed name lands on the existing category rather
-- than forking the taxonomy.
local PERSON_BARE = {["1"] = "متكلم", ["2"] = "مخاطب", ["3"] = "غائب"}
local NUMBER_BARE = {s = "مفرد", d = "مثنى", p = "جمع"}
local GENDER_BARE = {m = "مذكر", f = "مؤنث"}
local TENSE_BARE = {past = "ماضي", ind = "مضارع", sub = "مضارع", juss = "مضارع", imp = "أمر"}
local VOICE_BARE = {act = "مبني للمعلوم", pass = "مبني للمجهول"}

local PRONOUN = {
	["1s"]  = "أَنَا",   ["1p"]  = "نَحْنُ",
	["2ms"] = "أَنْتَ",   ["2fs"] = "أَنْتِ",   ["2d"]  = "أَنْتُمَا",
	["2mp"] = "أَنْتُمْ",  ["2fp"] = "أَنْتُنَّ",
	["3ms"] = "هُوَ",    ["3fs"] = "هِيَ",
	["3md"] = "هُمَا",   ["3fd"] = "هُمَا",
	["3mp"] = "هُمْ",    ["3fp"] = "هُنَّ",
}

-- Slots that carry no conjugated form of their own: the five *_linked duplicates
-- and the staging slots Module:ar-verb uses while picking a default.
local SKIP_SLOTS = {
	ap1 = true, ap2 = true, ap3 = true, apcd = true, apan = true,
	pp2 = true, vn2 = true,
}

-- Derived nominals. They are lemmas in their own right (اسم فاعل / اسم مفعول /
-- مصدر), not conjugated forms, so this module does not claim them.
local NOMINAL_SLOTS = {ap = true, pp = true, vn = true}

local QUADRILITERAL = {
	["فعلل"] = true, ["تفعلل"] = true, ["افعنلل"] = true, ["افعللّ"] = true,
}

local MAINTENANCE_CATEGORY = "صفحات صيغ أفعال بلا مطابقة"

-- Ends the float started by the box, so the conjugation table below it starts on
-- a clean line rather than wrapping around it.
local CLEAR = '<div class="avf-clear"></div>'

-- |قسم= names one section to render instead of the whole page. The bot writes no
-- قسم at all on all ~949,000 pages and gets الكل; the named values exist for the
-- handful of pages a contributor wants to write inside, which they do by
-- replacing the single call with one call per section and typing in the gaps.
--
-- The values *read* like the headings but are not the headings. The module owns
-- the heading text — and «صيغ تشارك الرسم «رميت»» computes half of its own — so
-- keeping them apart means renaming a heading stays a one-line change here
-- instead of an edit to every page that was split.
local SECTIONS = {
	["الكل"]             = "all",
	["صيغة فعل"]         = "form",
	["صيغة الفعل"]       = "form",
	["النطق"]            = "pron",
	["التصريفات"]        = "conj",
	["الرسم"]            = "rasm",
	["صيغ تشارك الرسم"]  = "rasm",
}

-- Named in the order they appear on the page, for the error message.
local SECTION_NAMES = "الكل، صيغة فعل، النطق، التصريفات، الرسم"

-- The box floats, so whatever comes after it has to be told to start below it.
-- On the whole page that is the conjugation table. On a split page صيغة فعل has
-- to clear on its way out — otherwise the contributor's own section wraps up
-- beside the box — and التصريفات has to clear on its way in, because it can no
-- longer know what precedes it.
local CLEARS = {all = true, form = true, conj = true}

-------------------------------------------------------------------------------
-- Text helpers
-------------------------------------------------------------------------------

-- Tatweel and the invisible direction/joining marks. These survive copy-paste
-- and round-trips through external tools, and MediaWiki keeps them in page
-- titles, so both sides of every comparison are cleaned of them first.
local INVISIBLE = "[" .. u(0x0640) .. u(0x061C) .. u(0x200B) .. "-" .. u(0x200F)
	.. u(0x202A) .. "-" .. u(0x202E) .. u(0xFEFF) .. "]"

-- The diacritics that are not typed when searching: the harakat proper, shadda,
-- sukun and the dagger alif. Hamza carriers (أ إ آ ؤ ئ) are distinct letters and
-- are deliberately left alone.
local DIACRITIC = "[" .. u(0x064B) .. "-" .. u(0x0652) .. u(0x0670) .. "]"

local function clean(s)
	if not s or s == "" then return "" end
	return (mw.ustring.gsub(s, INVISIBLE, ""))
end

-- The consonant skeleton (الرسم) a reader would type into the search box.
local function rasm(s)
	return (mw.ustring.gsub(clean(s), DIACRITIC, ""))
end

local function link(target, display)
	if not target or target == "" then return "" end
	if display and display ~= target then
		return "[[" .. target .. "|" .. display .. "]]"
	end
	return "[[" .. target .. "]]"
end

local function category(name)
	return "[[تصنيف:" .. name .. "]]"
end

-- A *link to* a category needs the leading colon; without it the page silently
-- joins the category instead of pointing at it.
local function category_link(name, display)
	return "[[:تصنيف:" .. name .. "|" .. (display or name) .. "]]"
end

-- Template calls returned from a module are not expanded — the preprocessor has
-- already run by the time Scribunto hands the text back — so they have to go
-- through the frame.
local function expand(title, args)
	local frame = mw.getCurrentFrame()
	if not frame then
		return ""
	end
	local ok, result = pcall(frame.expandTemplate, frame, {title = title, args = args})
	return ok and result or ""
end

-- "أكل" -> "أ ك ل". ar.wiktionary spells root categories and navboxes from the
-- letters as written (تصنيف:أ ك ل, 15 pages), not from the normalized radicals
-- Module:ar-verb reports, which would give ء ك ل and point at nothing.
local function space_root(root)
	local letters = {}
	for letter in mw.ustring.gmatch(clean(root), ".") do
		if letter ~= " " and letter ~= "_" then letters[#letters + 1] = letter end
	end
	return table.concat(letters, " ")
end

-------------------------------------------------------------------------------
-- Slots
-------------------------------------------------------------------------------

-- "past_pass_3ms" -> tense "past", voice "pass", person/number "3ms"
-- "imp_2fs"       -> tense "imp",  voice "act",  person/number "2fs"
local function parse_slot(slot)
	local tense, rest = mw.ustring.match(slot, "^(%a+)_(.+)$")
	if not tense or not TENSE[tense] then return nil end
	local voice = "act"
	local pn = rest
	local stripped = mw.ustring.match(rest, "^pass_(.+)$")
	if stripped then
		voice = "pass"
		pn = stripped
	end
	local person, gender, number = mw.ustring.match(pn, "^(%d)([mf]?)([sdp])$")
	if not person then return nil end
	return {
		slot = slot, tense = tense, voice = voice, pn = pn,
		person = person, gender = gender, number = number,
	}
end

-- Every slot that can name a conjugated form, in the order Module:ar-verb
-- declares them.
local function each_form_slot(spec, fn)
	for _, slot_accel in ipairs(spec.verb_slots) do
		local slot = slot_accel[1]
		if not SKIP_SLOTS[slot] and not NOMINAL_SLOTS[slot]
			and not mw.ustring.find(slot, "_linked$") then
			local forms = spec.forms[slot]
			if forms then
				for _, form in ipairs(forms) do
					if form.form and form.form ~= "" and form.form ~= "?" then
						fn(slot, form.form)
					end
				end
			end
		end
	end
end

-- Name the mood only where the reader would otherwise be misled.
--
-- The simplified conjugation table — what {{تصريف}} shows by default — holds the
-- active الماضي، المضارع and الأمر and nothing else, and its المضارع column is the
-- indicative. A form the reader can find there has nothing on the table to
-- contrast with, so spelling out المرفوع is pedantry: تُحْسِبَانِ is simply المضارع.
--
-- المرفوع is worth saying in the two cases where it is not: the passive, which
-- only the full table (كامل=1) shows, and forms whose moods genuinely part ways —
-- يَكْتُبُوا is منصوب ومجزوم, against يَكْتُبُونَ for the indicative. Where all three moods
-- produce one string (يَكْتُبْنَ) they collapse back to a bare المضارع.
local function tense_phrase(tenses, voice)
	if tenses.past then return TENSE.past end
	if tenses.imp then return TENSE.imp end
	if tenses.ind and (voice == "act" or (tenses.sub and tenses.juss)) then
		return "المضارع"
	end
	local parts = {}
	for _, t in ipairs({"ind", "sub", "juss"}) do
		if tenses[t] then parts[#parts + 1] = TENSE[t] end
	end
	if #parts == 0 then return nil end
	if #parts == 1 then return parts[1] end
	-- "المضارع المنصوب والمجزوم" — say المضارع once, then the moods.
	local moods = {}
	for i, phrase in ipairs(parts) do
		moods[i] = (mw.ustring.gsub(phrase, "^المضارع ", ""))
	end
	return "المضارع " .. table.concat(moods, " و")
end

-- Group the matched slots into one statement per (voice, person/number).
local function group_matches(matches)
	local order, groups = {}, {}
	for _, m in ipairs(matches) do
		local key = m.voice .. "|" .. m.pn
		if not groups[key] then
			groups[key] = {voice = m.voice, pn = m.pn, person = m.person,
				gender = m.gender, number = m.number, tenses = {}}
			order[#order + 1] = key
		end
		groups[key].tenses[m.tense] = true
	end
	local out = {}
	for _, key in ipairs(order) do out[#out + 1] = groups[key] end
	return out
end

local function describe(group, lemma)
	local phrase = tense_phrase(group.tenses, group.voice)
	if not phrase then return nil end

	local parts = {phrase}
	-- The imperative is active by definition; saying so adds nothing.
	if not group.tenses.imp then
		parts[#parts + 1] = VOICE[group.voice]
	end

	-- ل + المتكلم contracts to للمتكلم, so the first word carries the merged
	-- article and the rest keep theirs: "للغائب الجمع المذكر".
	local who = {"لل" .. PERSON_BARE[group.person], NUMBER[group.number]}
	if group.gender and group.gender ~= "" then
		who[#who + 1] = GENDER[group.gender]
	end
	local pronoun = PRONOUN[group.pn]
	local subject = table.concat(who, " ")
	if pronoun then
		subject = subject .. " (" .. pronoun .. ")"
	end

	return table.concat(parts, " ") .. "، " .. subject .. "، من الفعل " .. link(lemma) .. "."
end

-------------------------------------------------------------------------------
-- The pattern this form realises (وزن الصيغة)
-------------------------------------------------------------------------------

-- Conjugating ف-ع-ل (ف-ع-ل-ل for quadriliterals) through the same وزن and the
-- same slot names the shape of the form: past_1s of فعَل يفعُل is فَعَلْتُ. That is the
-- category ar.wiktionary already files these pages under, and it is a browse
-- axis nothing else offers — every verb's first-person past in one place.
local pattern_cache = {}

local function pattern_forms(wazn)
	if pattern_cache[wazn] == nil then
		local model = QUADRILITERAL[wazn] and "فعلل" or "فعل"
		local ok, spec = pcall(ar_verb.do_generate_forms,
			{["جذر"] = model, ["وزن"] = wazn}, "ar-conj")
		pattern_cache[wazn] = ok and spec.forms or false
	end
	return pattern_cache[wazn] or nil
end

local function pattern_of(wazn, slot)
	local forms = pattern_forms(wazn)
	if not forms then return nil end
	local entry = forms[slot]
	if not entry or not entry[1] or entry[1].form == "?" then return nil end
	return entry[1].form
end

-------------------------------------------------------------------------------
-- Blocks
-------------------------------------------------------------------------------

-- The box: the form, then the lemma it belongs to, then the morphological
-- coordinates. Laid out and floated like {{صندوق معلومات فعل}} on the lemma page,
-- so the two read as one family.
--
-- The form and the lemma are set at the same size. The form names the page, but
-- the lemma is where the reader is being sent, and a title twice the size of the
-- link under it says the opposite.
local function render_box(verb)
	local box = mw.html.create("table"):addClass("ar-verb-form-box")

	local head = box:tag("tr"):tag("th"):attr("colspan", 2):addClass("avf-head")
	head:tag("div"):addClass("avf-form"):wikitext(verb.pagename)
	head:tag("div"):addClass("avf-of"):wikitext("صيغة من الفعل")
	head:tag("div"):addClass("avf-lemma"):wikitext(link(verb.lemma))
	head:tag("div"):addClass("avf-note")
		:wikitext("المعاني والشواهد والمراجع على صفحة الفعل")

	local function row(label, value)
		if not value or value == "" then return end
		local tr = box:tag("tr")
		tr:tag("th"):wikitext(label)
		tr:tag("td"):wikitext(value)
	end

	-- A form can belong to the same verb under more than one root analysis
	-- (آتَتْ is أ ت و and أ ت ي alike). Only the root differs, so only the root
	-- row repeats; everything else would be identical.
	local roots = {}
	for i, root_display in ipairs(verb.root_displays) do
		roots[i] = category_link(root_display)
	end
	row(#roots > 1 and "الجذور" or "الجذر", table.concat(roots, "، "))
	-- The wazn alone. The classification that goes with it (فعل ثلاثي مزيد بحرف)
	-- is stated in the conjugation table's own header further down the page.
	row("الوزن", verb.wazn)
	row("وزن الصيغة", verb.pattern_form and
		category_link(verb.pattern_form) or nil)

	return tostring(box)
end

-- Vocalizations of this same verb that share the page's consonant skeleton.
-- A reader searching أكلت lands on one of them and needs the others; this is
-- also why ar.wiktionary does not need a disambiguation page per skeleton.
local function collect_siblings(verbs, pagename)
	local skeleton = rasm(pagename)
	local seen, list = {[pagename] = true}, {}
	for _, verb in ipairs(verbs) do
		each_form_slot(verb.spec, function(_, form)
			local cleaned = clean(form)
			if not seen[cleaned] and rasm(cleaned) == skeleton then
				seen[cleaned] = true
				list[#list + 1] = cleaned
			end
		end)
	end
	table.sort(list)
	return list, skeleton
end

local function render_siblings(siblings, skeleton)
	if #siblings == 0 then return nil end
	local links = {}
	for i, form in ipairs(siblings) do links[i] = link(form) end
	return "=== صيغ تشارك الرسم «" .. skeleton .. "» ===\n"
		.. table.concat(links, " · ")
end

-- The dataset sometimes analyses one verb under two roots — آتَى is both أ ت و and
-- أ ت ي on وزن أفعل, and both produce آتَتْ. Left alone that renders two identical
-- boxes and two identical statements. Fold them into one entry whose only
-- plural field is the root; everything else was the same in both.
local function merge_equivalent(verbs)
	local order, seen = {}, {}
	for _, verb in ipairs(verbs) do
		local key = table.concat({verb.lemma, verb.wazn,
			table.concat(verb.sentences, "\1")}, "\2")
		local first = seen[key]
		if first then
			for _, root in ipairs(verb.roots) do
				first.roots[#first.roots + 1] = root
			end
			for _, display in ipairs(verb.root_displays) do
				first.root_displays[#first.root_displays + 1] = display
			end
		else
			seen[key] = verb
			order[#order + 1] = verb
		end
	end
	return order
end

-------------------------------------------------------------------------------
-- Core
-------------------------------------------------------------------------------

-- Work out what the page title is, without producing any wikitext. Kept apart
-- from p.render so the offline tests can assert on the grammar the module
-- derived rather than on the markup it happened to wrap it in.
--
-- args = {verbs = {{root=, wazn=}, ...}, pagename =}
-- returns {pagename=, verbs={...}, siblings={...}, skeleton=, categories={...}}
function p.analyze(args)
	local pagename = clean(args.pagename)
	local verbs = {}

	for _, entry in ipairs(args.verbs or {}) do
		local ok, spec = pcall(ar_verb.do_generate_forms,
			{["جذر"] = entry.root, ["وزن"] = entry.wazn}, "ar-conj")
		if ok and spec and spec.forms then
			local matches = {}
			each_form_slot(spec, function(slot, form)
				if clean(form) == pagename then
					local parsed = parse_slot(slot)
					if parsed then matches[#matches + 1] = parsed end
				end
			end)
			if #matches > 0 then
				local lemma_entry = spec.forms.past_3ms
				local groups = group_matches(matches)
				local sentences = {}
				local lemma = lemma_entry and lemma_entry[1] and lemma_entry[1].form
					or entry.root
				for i, group in ipairs(groups) do
					sentences[i] = describe(group, lemma)
				end
				verbs[#verbs + 1] = {
					root = entry.root,
					roots = {entry.root},
					wazn = entry.wazn,
					lemma = lemma,
					root_display = space_root(entry.root),
					root_displays = {space_root(entry.root)},
					pattern_form = pattern_of(entry.wazn, matches[1].slot),
					spec = spec,
					pagename = pagename,
					slots = matches,
					groups = groups,
					sentences = sentences,
				}
			end
		end
	end

	verbs = merge_equivalent(verbs)

	local siblings, skeleton = collect_siblings(verbs, pagename)

	-- Categories: the parent, then per statement the grammatical category in the
	-- order ar.wiktionary already names them (فعل + بناء + زمن + عدد + جنس + شخص),
	-- the pattern this form realises, and the root.
	local categories, seen_category = {}, {}
	local function add_category(name)
		if name and name ~= "" and not seen_category[name] then
			seen_category[name] = true
			categories[#categories + 1] = name
		end
	end

	if #verbs == 0 then
		add_category(MAINTENANCE_CATEGORY)
	else
		add_category("صيغ أفعال عربية")
		for _, verb in ipairs(verbs) do
			for _, group in ipairs(verb.groups) do
				local name = {"فعل"}
				if group.tenses.imp then
					-- The imperative categories carry neither voice nor person:
					-- تصنيف:فعل أمر مفرد مذكر, as {{أ-م-ذ}} files them today.
					name[#name + 1] = TENSE_BARE.imp
					name[#name + 1] = NUMBER_BARE[group.number]
					if group.gender and group.gender ~= "" then
						name[#name + 1] = GENDER_BARE[group.gender]
					end
				else
					local tense = group.tenses.past and "past" or "ind"
					name[#name + 1] = VOICE_BARE[group.voice]
					name[#name + 1] = TENSE_BARE[tense]
					name[#name + 1] = NUMBER_BARE[group.number]
					if group.gender and group.gender ~= "" then
						name[#name + 1] = GENDER_BARE[group.gender]
					end
					name[#name + 1] = PERSON_BARE[group.person]
				end
				add_category(table.concat(name, " "))
			end
			add_category(verb.pattern_form)
			for _, root_display in ipairs(verb.root_displays) do
				add_category(root_display ~= "" and root_display or nil)
			end
		end
	end

	return {
		pagename = pagename,
		verbs = verbs,
		siblings = siblings,
		skeleton = skeleton,
		categories = categories,
	}
end

-- args = {verbs = {{root=, wazn=}, ...}, pagename =, section =}
--
-- `section` is the raw |قسم= value, so the resolution and its error live on the
-- one path both the wiki and the offline tests go through.
function p.render(args)
	local requested = clean(mw.text.trim(args.section or ""))
	local section = requested == "" and "all" or SECTIONS[requested]

	-- A typo has to say so. At ~949,000 pages a |قسم=نطق that silently rendered
	-- nothing would leave a blank page nobody would ever find.
	if not section then
		return '<span class="error">وسيط «قسم» غير معروف: «' .. requested
			.. '». القيم المقبولة: ' .. SECTION_NAMES .. '.</span>'
			.. category(MAINTENANCE_CATEGORY)
	end

	local data = p.analyze(args)
	local pagename, verbs = data.pagename, data.verbs

	if #verbs == 0 then
		return "<span class=\"error\">لم تطابق هذه الصفحة أي صيغة من صيغ الفعل المذكور.</span>"
			.. category(MAINTENANCE_CATEGORY)
	end

	local whole = section == "all"
	local out = {}

	if whole or section == "form" then
		out[#out + 1] = "=== صيغة فعل ==="
		for _, verb in ipairs(verbs) do
			out[#out + 1] = render_box(verb)
			for _, sentence in ipairs(verb.sentences) do
				out[#out + 1] = "# " .. sentence
			end
		end
	end

	if whole or section == "pron" then
		local ipa = expand("أصد-عر", {pagename})
		if ipa ~= "" then
			out[#out + 1] = "=== النطق ==="
			out[#out + 1] = "* " .. ipa
		end
	end

	-- Lemma pages write {{-}} at this point; same effect, one fewer template.
	if CLEARS[section] then
		out[#out + 1] = CLEAR
	end

	if whole or section == "conj" then
		out[#out + 1] = "=== التصريفات ==="
		for _, verb in ipairs(verbs) do
			-- Full table, open, with the cells holding this page marked. The
			-- full table is what makes the marking worth anything: it is the only
			-- one that shows the passive and the moods, and a reader who arrived
			-- on a passive form needs to see where it sits.
			out[#out + 1] = expand("تصريف", {
				["جذر"] = verb.root,
				["وزن"] = verb.wazn,
				["كامل"] = "1",
				["موسع"] = "1",
				["بؤرة"] = pagename,
			})
		end
	end

	if whole or section == "rasm" then
		local siblings = render_siblings(data.siblings, data.skeleton)
		if siblings then out[#out + 1] = siblings end
	end

	-- Categories describe the page, not a section of it, so only the two renders
	-- that carry the form's identity emit them. Otherwise a stray
	-- {{صيغة فعل|قسم=النطق}} on an unrelated page would file it under
	-- تصنيف:فعل مبني للمجهول ماضي… on the strength of a pronunciation line.
	local categories = {}
	if whole or section == "form" then
		for i, name in ipairs(data.categories) do categories[i] = category(name) end
	end

	return table.concat(out, "\n") .. "\n" .. table.concat(categories, "")
end


-------------------------------------------------------------------------------
-- Entry point
-------------------------------------------------------------------------------

function p.main(frame)
	local args = frame:getParent() and frame:getParent().args or frame.args

	local verbs = {}
	for i = 1, MAX_VERBS do
		local suffix = i == 1 and "" or tostring(i)
		local root = args["جذر" .. suffix]
		local wazn = args["وزن" .. suffix]
		if root and root ~= "" and wazn and wazn ~= "" then
			verbs[#verbs + 1] = {
				root = mw.text.trim(root),
				wazn = mw.text.trim(wazn),
			}
		end
	end

	local pagename = args["صفحة"]
	if not pagename or pagename == "" then
		pagename = mw.title.getCurrentTitle().text
	end

	return p.render({
		verbs = verbs,
		pagename = pagename,
		section = args["قسم"],
	})
end

return p