Skip to content

Commit 66e280a

Browse files
mosaic-making concept exercise for arrays (#181)
Every concept exercise exemplar uses only words taught in that exercise or prerequisite exercises.
1 parent 9452c97 commit 66e280a

34 files changed

Lines changed: 1099 additions & 155 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ bin/latest-configlet.tar.gz
55
bin/latest-configlet.zip
66
bin/configlet.zip
77
test-exercises/
8+
known-words.md

bin/detect-unknown-words

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
#!/usr/bin/env python3
2+
3+
# Synopsis:
4+
# Report words that an exercise's reference solution uses but that
5+
# haven't been taught yet -- either in the exercise's own
6+
# .docs/introduction.md (concept exercise) or in any transitive
7+
# prereq's introduction (concept and practice exercises).
8+
#
9+
# Reads the per-exercise .meta/known-words.md files written by
10+
# bin/extract-known-words; run that first whenever introductions
11+
# change.
12+
#
13+
# Usage:
14+
# bin/detect-unknown-words # both concept and practice
15+
# bin/detect-unknown-words concept # only concept exercises
16+
# bin/detect-unknown-words practice # only practice exercises
17+
18+
import re, sys, json
19+
from pathlib import Path
20+
21+
ROOT = Path(__file__).resolve().parent.parent
22+
CFG = json.loads((ROOT / 'config.json').read_text())
23+
24+
SYNTAX = set("[ ] { } ( ) [| | ; T{ H{ V{".split())
25+
SYNTAX |= set("USING: IN: CONSTANT: SYMBOL: SYMBOLS: TUPLE: ERROR: PREDICATE: FROM: BUILTIN: HOOK: DEFER:".split())
26+
SYNTAX |= set("PRIVATE> <PRIVATE QUALIFIED:".split())
27+
PRIMITIVES = set(":: : ; -- :>".split())
28+
29+
30+
# Map: concept slug -> exercise slug that teaches it (concept exercises only)
31+
CONCEPT_TO_EX = {}
32+
EX_META = {ex['slug']: ex for ex in CFG['exercises']['concept']}
33+
for ex in CFG['exercises']['concept']:
34+
for c in ex['concepts']:
35+
CONCEPT_TO_EX[c] = ex['slug']
36+
37+
38+
def known_words_for_concept_ex(slug):
39+
"""Read the concept exercise's known-words.md and return the word set."""
40+
p = ROOT / 'exercises' / 'concept' / slug / '.meta' / 'known-words.md'
41+
if not p.exists():
42+
return set()
43+
return {m.group(1) for m in re.finditer(r'^- `([^`]+)`', p.read_text(), re.M)}
44+
45+
46+
def transitive_prereq_concept_exes(prereq_concepts):
47+
"""Walk the concept-prereq graph; return concept-exercise slugs."""
48+
seen = set()
49+
queue = list(prereq_concepts)
50+
while queue:
51+
c = queue.pop(0)
52+
if c in CONCEPT_TO_EX:
53+
ex = CONCEPT_TO_EX[c]
54+
if ex not in seen:
55+
seen.add(ex)
56+
queue.extend(EX_META[ex]['prerequisites'])
57+
return seen
58+
59+
60+
def parse_solution(path):
61+
"""Return (defined, locals, used) for a Factor solution file."""
62+
text = path.read_text()
63+
defined, locals_ = set(), set()
64+
65+
# Top-level word definitions
66+
for m in re.finditer(r'^::?\s+(\S+)\s*\(', text, re.M):
67+
defined.add(m.group(1))
68+
# Locals from :: (locals) input list
69+
for m in re.finditer(r'^::\s+\S+\s*\(([^)]*)--[^)]*\)', text, re.M):
70+
for tok in m.group(1).split():
71+
if not tok.endswith(':') and not tok.startswith('('):
72+
locals_.add(tok)
73+
# Mutable variant: a! is the setter for local a, but the local itself is a
74+
if tok.endswith('!'):
75+
locals_.add(tok[:-1])
76+
# Locals lambda [| a b | ... ]
77+
for m in re.finditer(r'\[\|\s*([^|]+?)\|', text):
78+
for tok in m.group(1).split():
79+
locals_.add(tok)
80+
if tok.endswith('!'):
81+
locals_.add(tok[:-1])
82+
# `:> { a b c }` introduces multiple locals
83+
for m in re.finditer(r':>\s+\{([^}]+)\}', text):
84+
for tok in m.group(1).split():
85+
locals_.add(tok)
86+
if tok.endswith('!'):
87+
locals_.add(tok[:-1])
88+
# `:> name` or `:> name!` introduces a single local (mutable variant adds both name and name!)
89+
for m in re.finditer(r':>\s+(\S+)', text):
90+
n = m.group(1)
91+
if n.startswith('{'):
92+
continue
93+
locals_.add(n)
94+
if n.endswith('!'):
95+
locals_.add(n[:-1])
96+
97+
for m in re.finditer(r'^\s*CONSTANT:\s+(\S+)', text, re.M):
98+
defined.add(m.group(1))
99+
for m in re.finditer(r'^\s*SYMBOL:\s+(\S+)', text, re.M):
100+
defined.add(m.group(1))
101+
for m in re.finditer(r'^\s*SYMBOLS:\s+([^;]+);', text, re.M):
102+
defined.update(m.group(1).split())
103+
for m in re.finditer(r'^\s*DEFER:\s+(\S+)', text, re.M):
104+
defined.add(m.group(1))
105+
for m in re.finditer(r'^\s*TUPLE:\s+(\S+)\s*([^;]*);?', text, re.M):
106+
defined.add(m.group(1))
107+
sig = m.group(2)
108+
for sb in re.finditer(r'\{\s*(\S+)[^}]*\}', sig):
109+
slot = sb.group(1)
110+
defined.update([slot, f'{slot}>>', f'>>{slot}', f'change-{slot}'])
111+
depth = 0
112+
for tok in sig.split():
113+
if tok == '{':
114+
depth += 1; continue
115+
if tok == '}':
116+
depth -= 1; continue
117+
if depth == 0:
118+
defined.update([tok, f'{tok}>>', f'>>{tok}', f'change-{tok}'])
119+
for m in re.finditer(r'^\s*ERROR:\s+(\S+)\s*([^;]*);?', text, re.M):
120+
defined.add(m.group(1))
121+
for tok in m.group(2).split():
122+
defined.update([tok, f'{tok}>>', f'>>{tok}', f'change-{tok}'])
123+
124+
# USING: vocabulary names — multi-line aware
125+
imports = set()
126+
for m in re.finditer(r'USING:\s+(.*?);', text, re.S):
127+
imports.update(m.group(1).split())
128+
129+
body = re.sub(r'\(\s*(?:[^()]|\([^)]*\))*\s*\)', '', text)
130+
body = re.sub(r'!\s.*', '', body)
131+
body = re.sub(r'"[^"\n]*"', '', body)
132+
body = re.sub(r'CHAR:\s+\S+', '', body)
133+
body = re.sub(r'^\s*USING:.*?;', '', body, flags=re.M | re.S)
134+
body = re.sub(r'^\s*IN:.*$', '', body, flags=re.M)
135+
body = re.sub(r'\[\|[^|]*\|', '', body)
136+
137+
used = set()
138+
for tok in body.split():
139+
if not tok:
140+
continue
141+
if tok in defined or tok in locals_:
142+
continue
143+
if tok in SYNTAX or tok in PRIMITIVES:
144+
continue
145+
if re.fullmatch(r"-?\d[\d_]*(\.\d+)?(/-?\d+)?(e-?\d+)?", tok):
146+
continue
147+
if re.fullmatch(r'0x[0-9a-fA-F]+|0b[01]+', tok):
148+
continue
149+
if tok in imports:
150+
continue
151+
if re.fullmatch(r'[()\[\]{}|;]+', tok):
152+
continue
153+
used.add(tok)
154+
return defined, locals_, used
155+
156+
157+
def check_concept(slug, sol_path):
158+
"""Concept exercise: known = its own known-words.md (which already
159+
includes own intro plus transitive prereq intros)."""
160+
known = known_words_for_concept_ex(slug)
161+
defined, locals_, used = parse_solution(sol_path)
162+
return sorted(used - known - defined - locals_)
163+
164+
165+
def check_practice(prereqs, sol_path):
166+
"""Practice exercise: known = union over each prereq concept's
167+
known-words.md (which itself transitively folds in its own prereqs)."""
168+
known = set()
169+
for cx in transitive_prereq_concept_exes(prereqs):
170+
known |= known_words_for_concept_ex(cx)
171+
defined, locals_, used = parse_solution(sol_path)
172+
return sorted(used - known - defined - locals_)
173+
174+
175+
def main():
176+
args = sys.argv[1:]
177+
if not args:
178+
kinds = ('concept', 'practice')
179+
elif args[0] in ('concept', 'practice'):
180+
kinds = (args[0],)
181+
else:
182+
sys.exit(f'Usage: {sys.argv[0]} [concept|practice]')
183+
184+
issues = 0
185+
if 'concept' in kinds:
186+
print('=== concept exercises ===')
187+
any_concept = False
188+
for ex in CFG['exercises']['concept']:
189+
slug = ex['slug']
190+
sol = ROOT / 'exercises' / 'concept' / slug / '.meta' / 'exemplar.factor'
191+
if not sol.exists():
192+
continue
193+
unknowns = check_concept(slug, sol)
194+
if unknowns:
195+
any_concept = True
196+
issues += 1
197+
print(f'{slug}: {unknowns}')
198+
if not any_concept:
199+
print('(none)')
200+
201+
if 'practice' in kinds:
202+
print('=== practice exercises ===')
203+
any_practice = False
204+
for ex in CFG['exercises'].get('practice', []):
205+
slug = ex['slug']
206+
sol = ROOT / 'exercises' / 'practice' / slug / '.meta' / 'example.factor'
207+
if not sol.exists():
208+
continue
209+
unknowns = check_practice(ex.get('prerequisites', []), sol)
210+
if unknowns:
211+
any_practice = True
212+
issues += 1
213+
print(f'{slug}: {unknowns}')
214+
if not any_practice:
215+
print('(none)')
216+
217+
sys.exit(1 if issues else 0)
218+
219+
220+
if __name__ == '__main__':
221+
main()

0 commit comments

Comments
 (0)