// Package grammar provides Japanese morphological deinflection ported from
// Yomitan (github.com/yomidevs/yomitan, GPL-3.0-or-later) at commit
// e7334a96106a6c74e6fa75bd33a5ea1219d92712.
//
// Source files ported:
// - ext/js/language/ja/japanese-transforms.js
// - ext/js/language/language-transformer.js
// - ext/js/language/language-transforms.js
//
// # Suffix Splitting & Sound Changes
//
// The Suffix of each Step is designed to let callers split the surface text into
// constituent parts:
//
// Base stem + Step[0].Suffix + Step[1].Suffix + ... + Step[k-1].Suffix
//
// spells the original surface word where regular inflectional boundaries apply.
//
// In Japanese morphology, auxiliary inflections frequently rewrite earlier text
// through euphonic sound changes (音便 onbin / 音韻変化):
//
// 1. Geminate / Sokuon (促音便) on Adjectives:
// When past -た, conditional -たら, or representative -たり attaches to an
// i-adjective or negative form ending in い (e.g. ない -> なかった), the
// Yomitan transform rule matches かった -> い. Here, the geminate かっ
// belongs to the inflected stem of the preceding step (e.g. なかっ), while
// た / たら / たり is the suffix of the inflection step itself.
//
// 2. Continuative (連用形) Ku-change before Negative or -て:
// When negative ない or -て attaches to an i-adjective (e.g. 見たい -> 見たくない),
// the Yomitan rule matches くない -> い or くて -> い. The continuative く
// is attributed to the preceding stem (e.g. たく), while ない or て is the
// suffix of the negative or conjunctive step.
//
// 3. Conditional (仮定形) Ba-change:
// When -ば attaches to an i-adjective (e.g. ない -> なければ), the Yomitan rule
// matches ければ -> い. The けれ is attributed to the preceding stem (なけれ),
// while ば is attributed to -ば.
//
// 4. Overlapping Auxiliary Prefixes:
// In progressive or auxiliary verbs like -いる (-te iru) matching でいる or
// ている, the preceding で or て is preserved with the -て step, and いる
// is attributed to the -いる step (e.g. 読 + んで + いる).
//
// 5. Slang / Contraction Transforms:
// When slang or colloquial contractions rewrite across morpheme boundaries
// (e.g. -ゃ contracting ければ -> きゃ in しなきゃ, or n-slang contracting
// られない -> らんない in 食べらんない, or Kansai-ben onbin like こうて from
// かって), intermediate steps that are completely subsumed by the contraction
// have Suffix set to "", and the contraction step carries the fused surface
// morpheme.
package grammar
import (
"strings"
)
// Step is one inflectional transformation applied to a base word.
type Step struct {
Name string // yomitan transform name, e.g. "passive", "-nai", "-ta"
Description string // yomitan's description text
Suffix string // the inflected text this step added on the surface, e.g. "られ"; "" if not attributable
}
// Result is one deinflected candidate produced by the deinflector.
type Result struct {
Base string // deinflected dictionary form, e.g. "食べる"
Conditions []string // yomitan condition names of Base, e.g. ["v1"]
Steps []Step // from the dictionary form outward: causative, passive, negative, past
conditionFlags uint32 // condition bitmask for hierarchy checks
baseStem string // stem retained from Base
}
// MatchesCondition reports whether the result satisfies the given Yomitan
// condition name (e.g. "v1", "v5", "adj-i") according to Yomitan's condition
// inheritance hierarchy.
func (r Result) MatchesCondition(condition string) bool {
f, ok := ConditionFlags[condition]
if !ok {
return false
}
return r.conditionFlags == 0 || (r.conditionFlags&f) != 0
}
// BaseStem returns the stem of Base that precedes the first inflectional suffix.
// For example, for Base="食べる" with causative suffix "させ", BaseStem is "食べ".
// When concatenated with Steps in order, BaseStem + Step[0].Suffix + ... reproduces
// the surface word where inflectional boundaries permit.
func (r Result) BaseStem() string {
if len(r.Steps) == 0 {
return r.Base
}
if r.baseStem != "" {
return r.baseStem
}
return computeBaseStem(r.Base, r.Steps)
}
// frame represents one step in the deinflection trace.
// In Yomitan, trace frames are prepended, so trace[0] is the outermost deinflection
// step applied, but when reversed it represents outward steps from Base.
type traceFrame struct {
transformID int
ruleIndex int
textBefore string // text before this deinflection step was applied
}
type queueItem struct {
text string
conditions uint32
condNamesOut []string
trace []traceFrame // trace[0] is most recent deinflection step (innermost in inflection)
}
// Deinflect returns every deinflection candidate for word, in Yomitan's exact
// BFS transformation order.
func Deinflect(word string) []Result {
if word == "" {
return nil
}
queue := []queueItem{{
text: word,
conditions: 0,
condNamesOut: nil,
trace: nil,
}}
for i := 0; i < len(queue); i++ {
item := queue[i]
for tIdx := 0; tIdx < len(transforms); tIdx++ {
t := &transforms[tIdx]
for rIdx := 0; rIdx < len(t.rules); rIdx++ {
r := &t.rules[rIdx]
// Condition check
if item.conditions != 0 && (item.conditions&r.conditionsIn) == 0 {
continue
}
// Inflection match check
if r.isSuffix {
if !strings.HasSuffix(item.text, r.pattern) {
continue
}
} else {
if item.text != r.pattern {
continue
}
}
// Cycle check
isCycle := false
for _, f := range item.trace {
if f.transformID == tIdx && f.ruleIndex == rIdx && f.textBefore == item.text {
isCycle = true
break
}
}
if isCycle {
continue
}
// Deinflect
var newText string
if r.isSuffix {
newText = item.text[:len(item.text)-len(r.pattern)] + r.deinflected
} else {
newText = r.deinflected
}
// Prepend to trace (matches Yomitan's _extendTrace)
newTrace := make([]traceFrame, len(item.trace)+1)
newTrace[0] = traceFrame{
transformID: tIdx,
ruleIndex: rIdx,
textBefore: item.text,
}
copy(newTrace[1:], item.trace)
queue = append(queue, queueItem{
text: newText,
conditions: r.conditionsOut,
condNamesOut: r.condNamesOut,
trace: newTrace,
})
}
}
}
results := make([]Result, len(queue))
for i, q := range queue {
stem, steps := buildSteps(q.text, word, q.trace)
results[i] = Result{
Base: q.text,
Conditions: q.condNamesOut,
Steps: steps,
conditionFlags: q.conditions,
baseStem: stem,
}
}
return results
}
type soundChange struct {
deinfTarget string
infTarget string
stemPart string
suffixPart string
}
var soundChanges = [...]soundChange{
{"い", "かった", "かっ", "た"},
{"い", "かったら", "かっ", "たら"},
{"い", "かったり", "かっ", "たり"},
{"い", "くない", "く", "ない"},
{"い", "くて", "く", "て"},
{"い", "ければ", "けれ", "ば"},
{"い", "さそう", "さ", "そう"},
{"ます", "ました", "まし", "た"},
{"ません", "ませんでした", "ません", "でした"},
}
// buildSteps converts Yomitan trace frames into outward Steps from the Base form,
// calculating each Step's attributable surface Suffix.
func buildSteps(base, surface string, trace []traceFrame) (string, []Step) {
k := len(trace)
if k == 0 {
return base, nil
}
// trace in queueItem is prepended: trace[0] is the step that deinflected to Base!
// That means trace[0] is the innermost step (closest to Base), and trace[k-1]
// is the outermost step (which was applied on the surface).
// This matches the outward order required for Steps: causative, passive, negative, past.
steps := make([]Step, k)
suffixes := make([]string, k)
deinfs := make([]string, k)
infs := make([]string, k)
for i := 0; i < k; i++ {
f := trace[i]
t := &transforms[f.transformID]
r := &t.rules[f.ruleIndex]
steps[i] = Step{
Name: t.id,
Description: t.description,
}
if r.isSuffix {
infs[i] = r.pattern
deinfs[i] = r.deinflected
} else {
infs[i] = r.pattern
deinfs[i] = r.deinflected
}
suffixes[i] = infs[i]
}
for i := 0; i < k-1; i++ {
dNext := deinfs[i+1]
infNext := infs[i+1]
matchedSC := false
for _, sc := range soundChanges {
if dNext == sc.deinfTarget && infNext == sc.infTarget {
suffixes[i+1] = sc.suffixPart
if strings.HasSuffix(suffixes[i], dNext) {
suffixes[i] = suffixes[i][:len(suffixes[i])-len(dNext)] + sc.stemPart
}
matchedSC = true
break
}
}
if !matchedSC {
if dNext != "" && strings.HasPrefix(infNext, dNext) {
suffixes[i+1] = infNext[len(dNext):]
} else if dNext != "" && strings.HasSuffix(suffixes[i], dNext) {
suffixes[i] = suffixes[i][:len(suffixes[i])-len(dNext)]
}
}
}
// Calculate base stem
d0 := deinfs[0]
var baseStem string
if d0 != "" && strings.HasSuffix(base, d0) {
baseStem = base[:len(base)-len(d0)]
} else {
baseStem = base
}
// Verify surface reconstruction
var totalSuffix strings.Builder
for _, s := range suffixes {
totalSuffix.WriteString(s)
}
spelled := baseStem + totalSuffix.String()
if spelled != surface {
// Fallback for single-step or special contractions
if strings.HasPrefix(surface, baseStem) {
if k == 1 {
suffixes[0] = surface[len(baseStem):]
}
} else {
// Stem sound change (e.g. くる -> こない)
if strings.HasSuffix(surface, totalSuffix.String()) {
baseStem = surface[:len(surface)-len(totalSuffix.String())]
}
}
}
for i := 0; i < k; i++ {
steps[i].Suffix = suffixes[i]
}
return baseStem, steps
}
func computeBaseStem(base string, steps []Step) string {
if len(steps) == 0 {
return base
}
var totalSuffix strings.Builder
for _, s := range steps {
totalSuffix.WriteString(s.Suffix)
}
sufLen := totalSuffix.Len()
// If base ends with a dictionary form ending that was replaced by Step[0]:
// We can check transforms rules or common verb/adjective endings.
for _, end := range []string{"る", "す", "く", "ぐ", "む", "ぶ", "ぬ", "つ", "う", "い", "くる", "する", "ずる"} {
if strings.HasSuffix(base, end) {
return base[:len(base)-len(end)]
}
}
if len(base) > sufLen {
return base[:len(base)-sufLen]
}
return base
}