Files
Claro/tools/validate_learning_syntax.py
T

138 lines
8.5 KiB
Python

#!/usr/bin/env python3
"""Keep the introductory learning path consistent and executable."""
import os
import json
from pathlib import Path
import re
import subprocess
import tempfile
ROOT = Path(__file__).resolve().parents[1]
EXE = Path(os.environ.get('CLARO_EXE', str(ROOT / ('claro.exe' if os.name == 'nt' else 'claro')))).resolve()
DOCS = ['README.md', 'docs/QUICK_START.md', 'docs/FIRST_HOUR.md', 'docs/SIMPLE_FUNCTIONS.md']
LEGACY = re.compile(r'^\s*(?:CALL\b|LEARNED\b|ENDIF\b|DONE\b|TEACH\b[^\n]*\bTAKES\b|SET\s+\w+\s+TO\b|ASK\s+"(?:[^"\\]|\\.)*"\s+AS\b)', re.MULTILINE)
LESSONS = {
'01_hello.claro': ('', 'Lesson 1: Hello\nClaro can show messages with SAY.\nHello from Claro!\n'),
'02_ask_name.claro': ('Ada\n', 'Lesson 2: Ask a question\nWhat is your name?\nHello Ada\n'),
'03_variables.claro': ('', 'Lesson 3: Variables\nAlex\n5\n'),
'04_math.claro': ('', 'Lesson 4: Math\n5\n'),
'05_if_else.claro': ('', 'Lesson 5: Choices\nYou passed!\n'),
'06_loops.claro': ('', 'Lesson 6: Loops\nPractice\nPractice\nPractice\n1\n2\n3\n'),
'07_lists.claro': ('', 'Lesson 7: Lists\nmilk\nbread\n'),
'08_functions.claro': ('', 'Lesson 8: Functions with TEACH\nHello Alex\n'),
'09_files.claro': ('', 'Lesson 9: Files\nClaro saved this note.\n'),
'10_final_quiz.claro': ('4\n', 'Lesson 10: Tiny Quiz\nWhat is 2 + 2?\nCorrect!\n'),
}
QUICK = [('', 'Hello!\n'), ('Ada\n', 'What is your name?\nHello Ada\n'),
('', '10\n'), ('', 'Good job!\n'), ('', 'Practice\n' * 3),
('', 'milk\nbread\n'), ('', 'Hello Alex\n'), ('', 'Hello from Claro\n'),
('4\n', 'Tiny Quiz\nWhat is 2 + 2?\nCorrect!\n')]
FIRST_HOUR = [
('', 'Hello!\n'),
('Ada\n', 'What is your name?\nHello Ada\n'),
('', '0\n'),
('', '1\n'),
('4\n', 'What is 2 + 2?\nCorrect!\n'),
('', 'Practice helps\n' * 3),
('', 'milk\nbread\n'),
('', '5\n'),
('', 'Hello\n'),
]
def blocks(path):
return re.findall(r'```claro\s*\n(.*?)\n```', path.read_text(encoding='utf-8'), re.S)
def check_run(work, label, code, input_text, expected):
script = work / 'example.claro'
script.write_text(code + '\n', encoding='utf-8')
for command in ['check', 'typecheck']:
checked = subprocess.run([str(EXE), command, str(script)], cwd=work, text=True, capture_output=True, timeout=10)
assert checked.returncode == 0, (label, command, checked.stdout, checked.stderr)
result = subprocess.run([str(EXE), str(script)], cwd=work, input=input_text,
text=True, capture_output=True, timeout=10)
assert result.returncode == 0 and result.stdout == expected and not result.stderr, (label, result.returncode, result.stdout, result.stderr)
print('PASS:', label)
def main():
actual = {p.name for p in (ROOT / 'lessons').glob('*.claro')}
assert actual == set(LESSONS), ('Lesson coverage changed; update checker', actual)
sources = [(name, '\n'.join(blocks(ROOT / name))) for name in DOCS]
sources += [('lessons/' + name, (ROOT / 'lessons' / name).read_text()) for name in LESSONS]
examples = json.loads((ROOT / 'tests/learning_examples.json').read_text())
assert len(examples) == 10, 'Introductory example coverage changed'
sources += [('examples/' + name, (ROOT / 'examples' / name).read_text()) for name in examples]
for name, code in sources:
found = LEGACY.search(code)
assert not found, f'{name}: introductory syntax must use short ASK/SET, TEACH/DO, and END; found {found.group(0)!r}'
print('PASS: introductory syntax policy')
with tempfile.TemporaryDirectory(prefix='claro-learning-', dir=os.environ.get('TMPDIR')) as tmp:
work = Path(tmp)
modern_function = blocks(ROOT / 'docs/FIRST_HOUR.md')[7]
function_script = work / 'modern-function.claro'
function_script.write_text(modern_function + '\n')
for command in ['check', 'typecheck']:
result = subprocess.run([str(EXE), command, str(function_script)], cwd=work, text=True, capture_output=True, timeout=10)
assert result.returncode == 0, ('modern multi-argument function', command, result.stdout, result.stderr)
print('PASS: modern function accepted by', command)
mistakes = [
('SET', 'SET\n', 'Try: SET name "Alex"'),
('IF', 'IF YES\n SAY "yes"\n', 'IF block opened here needs END'),
('TEACH', 'TEACH greet name\n SAY name\n', 'TEACH block opened here needs END'),
('DO', 'DO 2 TIMES\n SAY "practice"\n', 'DO block opened here needs END'),
('FOR', 'FOR i FROM 1 TO 3\n SAY i\n', 'FOR block opened here needs END'),
('REPEAT', 'REPEAT\n SAY "practice"\n', 'REPEAT block opened here needs UNTIL'),
]
for label, code, hint in mistakes:
script = work / 'mistake.claro'
script.write_text(code)
result = subprocess.run([str(EXE), 'check', str(script)], cwd=work, text=True, capture_output=True, timeout=10)
assert result.returncode != 0 and hint in result.stdout, (label, result.returncode, result.stdout)
assert not re.search(r'needs (?:ENDIF|DONE|LEARNED)|SET name TO', result.stdout), result.stdout
print('PASS: beginner repair hint for', label)
first_hour = blocks(ROOT / 'docs/FIRST_HOUR.md')
assert len(first_hour) == len(FIRST_HOUR), 'First Hour block coverage changed'
for number, (code, (input_text, expected)) in enumerate(zip(first_hour, FIRST_HOUR), 1):
check_run(work, f'First Hour block {number}', code, input_text, expected)
check_run(work, 'First Hour quiz wrong answer', first_hour[4], '3\n',
'What is 2 + 2?\nThe answer is 4.\n')
check_run(work, 'quoted multi-argument function', 'TEACH greet first last\n RETURN first + last\nEND\nDO greet "Hello " "Ada"\nSAY RESULT', '', 'Hello Ada\n')
check_run(work, 'bare container constructors', 'SET items LIST\nSET pairs MAP\nSAY items\nSAY pairs', '', '[list]\n[map]\n')
for name, (input_text, expected) in LESSONS.items():
check_run(work, name, (ROOT / 'lessons' / name).read_text(), input_text, expected)
check_run(work, 'quiz wrong answer', (ROOT / 'lessons/10_final_quiz.claro').read_text(), '3\n', 'Lesson 10: Tiny Quiz\nWhat is 2 + 2?\nThe answer is 4.\n')
quick = blocks(ROOT / 'docs/QUICK_START.md')
assert len(quick) == len(QUICK), 'Quick Start block coverage changed'
for number, (code, (input_text, expected)) in enumerate(zip(quick, QUICK), 1):
check_run(work, f'Quick Start block {number}', code, input_text, expected)
functions = blocks(ROOT / 'docs/SIMPLE_FUNCTIONS.md')
assert len(functions) == 1, 'Function guide should teach one initial form'
check_run(work, 'simple function guide', functions[0], '', 'Hello Jon\n')
older = blocks(ROOT / 'docs/COMPATIBILITY.md')
assert len(older) == 1, 'Compatibility guide coverage changed'
compatibility = older[0]
check_run(work, 'legacy syntax remains supported', compatibility, '4\n', '5\nNumber?\n8\n8\n')
for name, (input_text, expected) in examples.items():
check_run(work, 'examples/' + name, (ROOT / 'examples' / name).read_text(), input_text, expected)
result = subprocess.run([str(EXE), 'new', 'Starter'], cwd=work, text=True, capture_output=True, timeout=10)
assert result.returncode == 0, result.stderr
starter = (work / 'Starter/main.claro').read_text()
assert not LEGACY.search(starter), 'Generated starter still teaches older syntax'
check_run(work, 'generated starter', starter, '', 'Welcome to Claro!\nHello Learner\nNice to meet you, Friend\n')
repl = subprocess.run([str(EXE), 'repl'], cwd=work, input='HELP\nEXIT\n', text=True, capture_output=True, timeout=10)
assert repl.returncode == 0 and 'SET name "Jon"' in repl.stdout and 'SET name TO' not in repl.stdout, repl.stdout
metadata = json.loads(subprocess.check_output([str(EXE), 'ide'], cwd=work, text=True))
snippets = {item['name']: item['body'] for item in metadata['snippets']}
for name in ['hello', 'if_else', 'function']:
assert not LEGACY.search(snippets[name]), ('IDE snippet still teaches older syntax', name)
check_run(work, 'IDE choice snippet', 'SET condition YES\n' + snippets['if_else'], '', 'Yes\n')
check_run(work, 'IDE function snippet', snippets['function'] + '\nDO name "Ada"', '', 'Ada\n')
print('PASS: REPL help and beginner IDE snippets')
print('Learning syntax validation complete')
if __name__ == '__main__':
main()