fix(dictionary): a German separated verb is a verb, never a joined noun or particle

A clause ending in a separable particle joined each word before it to the
particle and offered whatever the dictionary found, so "Er holt mich ab."
offered aber, "Er schläft ein." einer and "Er hört dem Lehrer zu." zudem.
Now only an entry that is a verb is offered, and a capitalised word after
the clause's first is taken for a noun and never joined.

The particles of fernsehen, hochheben, durchlesen, unterbringen,
übersetzen and bereitstellen were missing, as were the spoken rein, raus
and runter, so "Ich sehe heute Abend fern." now finds fernsehen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-14 17:44:46 +02:00
commit d3063e82e1
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 182 additions and 14 deletions

View file

@ -3,15 +3,17 @@ import 'package:halkyon_learn_flutter/src/feature/dictionary/model/sentence_word
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/spaced_words.dart';
/// The particles German separates from their verbs, which end a main
/// clause (`Ich rufe dich morgen an`: anrufen).
/// clause (`Ich rufe dich morgen an`: anrufen), with the spoken short forms
/// of her- and hin- (`rein`, `raus`, `runter`).
const _particles = {
'ab', 'an', 'auf', 'aus', 'bei', 'dabei', 'dar', 'davon', 'ein', //
'empor', 'entgegen', 'entlang', 'fest', 'fort', 'frei', 'gegenüber', //
'heim', 'her', 'herab', 'heran', 'herauf', 'heraus', 'herbei', //
'herein', 'herüber', 'herum', 'herunter', 'hervor', 'hin', 'hinab', //
'hinauf', 'hinaus', 'hinein', 'hinüber', 'hinunter', 'hinzu', 'kennen', //
'los', 'mit', 'nach', 'nieder', 'statt', 'teil', 'um', 'vor', 'voran', //
'voraus', 'vorbei', 'vorüber', 'weg', 'weiter', 'wieder', 'zu', //
'ab', 'an', 'auf', 'aus', 'bei', 'bereit', 'dabei', 'dar', 'davon', //
'durch', 'ein', 'empor', 'entgegen', 'entlang', 'fern', 'fest', 'fort', //
'frei', 'gegenüber', 'heim', 'her', 'herab', 'heran', 'herauf', //
'heraus', 'herbei', 'herein', 'herüber', 'herum', 'herunter', 'hervor', //
'hin', 'hinab', 'hinauf', 'hinaus', 'hinein', 'hinüber', 'hinunter', //
'hinzu', 'hoch', 'kennen', 'los', 'mit', 'nach', 'nieder', 'raus', //
'rein', 'runter', 'statt', 'teil', 'über', 'um', 'unter', 'vor', //
'voran', 'voraus', 'vorbei', 'vorüber', 'weg', 'weiter', 'wieder', 'zu', //
'zurecht', 'zurück', 'zusammen',
};
@ -20,8 +22,10 @@ final _word = RegExp(r"[\p{L}\p{M}]+(?:['’\-][\p{L}\p{M}]+)*", unicode: true);
final _clauseEnd = RegExp('[.,;:!?…–—()"„“”«»]');
/// Each clause of [sentence] that ends in a separable particle: its words
/// before it (from where they start), and the particle with where it
/// starts.
/// before it that may be its verb (from where they start), and the
/// particle with where it starts. A capitalised word is a noun unless it
/// opens the clause (`Er stellt das Essen ab`: no abessen), where the verb
/// of a question stands (`Rufst du mich an?`).
Iterable<({List<Match> before, Match particle})> _separated(
String sentence,
) sync* {
@ -43,12 +47,20 @@ Iterable<({List<Match> before, Match particle})> _separated(
_word.matchAsPrefix(sentence, offset + word.start)!,
];
yield (
before: inSentence.sublist(0, inSentence.length - 1),
before: [
for (final (index, word) in inSentence.indexed)
if (index < inSentence.length - 1 &&
(index == 0 || !_capitalised(word[0]!)))
word,
],
particle: inSentence.last,
);
}
}
/// Whether [word] starts with a capital letter.
bool _capitalised(String word) => word[0] != word[0].toLowerCase();
/// The key of a separated verb's form, its particle joined before it as a
/// subordinate clause writes it (`rufe` … `an`: `anrufe`), which the
/// dictionary has among the verb's forms.
@ -57,7 +69,8 @@ String _joined(Match particle, Match verb) =>
/// Every key a dictionary is asked for to cut German [sentence]: each word
/// as it stands ([spacedSentenceKeys]), and where a clause ends in a
/// separable particle, each word before it with the particle joined on.
/// separable particle, each word before it that may be its verb with the
/// particle joined on.
Set<String> germanSentenceKeys(String sentence) => {
...spacedSentenceKeys(sentence),
for (final (:before, :particle) in _separated(sentence))
@ -67,7 +80,8 @@ Set<String> germanSentenceKeys(String sentence) => {
/// German [sentence] cut into its words ([spacedSentenceParts]), a
/// separated verb (`rufe` … `an`) offered as the verb it is a form of
/// (anrufen) beside its stem and its particle, as the longer words running
/// over a word are ([SentencePart.wider]).
/// over a word are ([SentencePart.wider]): only an entry that is a verb,
/// as Wiktionary's part of speech says.
List<SentencePart> germanSentenceParts(
String sentence,
Map<String, List<DictionaryItem>> found,
@ -78,7 +92,9 @@ List<SentencePart> germanSentenceParts(
for (final stem in before) {
final entries = [
for (final item in found[_joined(particle, stem)] ?? const [])
if (item is DictionaryWord) item,
// Joined, a word of another kind may be a word too: ab and er
// are no aber.
if (item is DictionaryWord && item.partOfSpeech == 'verb') item,
];
if (entries.isEmpty) {
continue;

View file

@ -14,6 +14,20 @@ void main() {
final rufen = germanWord('rufen', pos: 'verb', glosses: ['to shout']);
final an = germanWord('an', pos: 'preposition', glosses: ['at']);
/// The entries of each longer word the part [text] of German [sentence]
/// offers beside itself, cut as [found] has its keys.
List<List<DictionaryItem>> widerAt(
String sentence,
String text,
Map<String, List<DictionaryItem>> found,
) => [
for (final wider in germanSentenceParts(
sentence,
found,
).singleWhere((part) => part.text == text).wider)
wider.entries,
];
test('a clause ending in a separable particle asks for each word before '
'it with the particle joined on, as subordinate clauses write it', () {
final keys = germanSentenceKeys('Ich rufe dich morgen an.');
@ -45,4 +59,142 @@ void main() {
// The particle's place in the line is where it stands.
expect(particle.start, 'Ich rufe dich morgen '.length);
});
test('only a verb is offered: a word before the particle joined to it '
'into a word of another kind is no separated verb', () {
final abholen = germanWord(
'abholen',
pos: 'verb',
glosses: ['to pick up'],
forms: ['abholt'],
);
final found = <String, List<DictionaryItem>>{
'er': [
germanWord('er', pos: 'pronoun', glosses: ['he']),
],
'holt': [
germanWord('holen', pos: 'verb', glosses: ['to fetch']),
],
'ab': [
germanWord('ab', pos: 'adverb', glosses: ['off']),
],
'aber': [
germanWord('aber', pos: 'conjunction', glosses: ['but']),
],
'abholt': [abholen],
'ein': [
germanWord('ein', pos: 'article', glosses: ['a']),
],
'einer': [
germanWord('einer', pos: 'pronoun', glosses: ['one']),
],
'dem': [
germanWord('der', pos: 'article', glosses: ['the']),
],
'zu': [
germanWord('zu', pos: 'preposition', glosses: ['to']),
],
'zudem': [
germanWord('zudem', pos: 'adverb', glosses: ['moreover']),
],
};
// ab and er are no aber, ein and er no einer, zu and dem no zudem.
expect(widerAt('Er holt mich ab.', 'ab', found), [
[abholen],
]);
expect(widerAt('Er holt mich ab.', 'Er', found), isEmpty);
expect(widerAt('Er schläft ein.', 'ein', found), isEmpty);
expect(widerAt('Er hört dem Lehrer zu.', 'zu', found), isEmpty);
});
test('a capitalised word is a noun unless it opens the clause, and is '
'never taken for the verb', () {
final abstellen = germanWord(
'abstellen',
pos: 'verb',
glosses: ['to put down'],
forms: ['abstellt'],
);
final anrufenAsked = germanWord(
'anrufen',
pos: 'verb',
glosses: ['to call'],
forms: ['anrufst'],
);
expect(
germanSentenceKeys('Er stellt das Essen ab.'),
allOf(contains('abstellt'), isNot(contains('abessen'))),
);
expect(
widerAt('Er stellt das Essen ab.', 'ab', {
'stellt': [
germanWord('stellen', pos: 'verb', glosses: ['to put']),
],
'essen': [
germanWord('Essen', pos: 'noun', glosses: ['food']),
],
'ab': [
germanWord('ab', pos: 'adverb', glosses: ['off']),
],
'abstellt': [abstellen],
'abessen': [
germanWord('abessen', pos: 'verb', glosses: ['to eat up']),
],
}),
[
[abstellen],
],
);
// A question opens with its verb.
expect(
widerAt('Rufst du mich an?', 'Rufst', {
'rufst': [rufen],
'an': [an],
'anrufst': [anrufenAsked],
}),
[
[anrufenAsked],
],
);
});
test('fern, hoch, durch, unter, über, bereit and the spoken rein, raus '
'and runter separate too', () {
for (final (sentence, key) in [
('Ich sehe heute Abend fern.', 'fernsehe'),
('Er hebt die Kiste hoch.', 'hochhebt'),
('Sie liest den Text durch.', 'durchliest'),
('Wir bringen die Gäste unter.', 'unterbringen'),
('Die Fähre setzt uns über.', 'übersetzt'),
('Sie stellt das Essen bereit.', 'bereitstellt'),
('Komm doch rein!', 'reinkomm'),
('Er geht raus.', 'rausgeht'),
('Sie kommt runter.', 'runterkommt'),
]) {
expect(germanSentenceKeys(sentence), contains(key), reason: sentence);
}
final fernsehen = germanWord(
'fernsehen',
pos: 'verb',
glosses: ['to watch television'],
forms: ['fernsehe'],
);
expect(
widerAt('Ich sehe heute Abend fern.', 'sehe', {
'sehe': [
germanWord('sehen', pos: 'verb', glosses: ['to see']),
],
'fern': [
germanWord('fern', pos: 'adjective', glosses: ['far']),
],
'fernsehe': [fernsehen],
}),
[
[fernsehen],
],
);
});
}