Files
storybook/tree-sitter-storybook/grammar.js
Sienna Meridian Satterwhite 80332971b8 fix(tree-sitter): resolve if keyword ambiguity in behavior nodes
Separate if(expr) condition from if(expr) { child } decorator to fix
grammar conflicts. Simplify decorator_params to accept only integer,
range, or duration.
2026-02-13 20:21:31 +00:00

445 lines
9.1 KiB
JavaScript

/**
* Tree-sitter grammar for Storybook DSL
*
* This grammar defines the syntax for the Storybook narrative DSL,
* including characters, templates, relationships, life arcs, behaviors, etc.
*/
module.exports = grammar({
name: 'storybook',
// externals: $ => [
// $._prose_block_content,
// $._prose_block_end
// ],
extras: $ => [
/\s/, // Whitespace
$.line_comment,
$.block_comment
],
conflicts: $ => [
[$.path_segments]
],
word: $ => $.identifier,
rules: {
// Top-level structure
source_file: $ => repeat($.declaration),
// Comments
line_comment: $ => token(seq('//', /.*/)),
block_comment: $ => token(seq('/*', /[^*]*\*+([^/*][^*]*\*+)*/, '/')),
// Declarations
declaration: $ => choice(
$.use_declaration,
$.character,
$.template,
$.life_arc,
$.schedule,
$.behavior,
$.institution,
$.relationship,
$.location,
$.species,
$.enum_declaration
),
// Use declarations
use_declaration: $ => seq(
'use',
$.path_segments,
optional(choice(
seq('::', '{', commaSep1($.identifier), '}'),
seq('::', '*')
)),
';'
),
path: $ => $.path_segments,
path_segments: $ => sep1($.identifier, token('::')),
// Character declaration
character: $ => seq(
'character',
field('name', $.identifier),
optional(seq(':', field('species', $.identifier))),
optional(field('template', $.template_clause)),
field('body', $.block)
),
template_clause: $ => seq('from', commaSep1($.identifier)),
// Template declaration
template: $ => seq(
'template',
field('name', $.identifier),
optional('strict'),
'{',
repeat($.include),
repeat($.field),
'}'
),
include: $ => seq('include', $.identifier),
// Fields (key: value pairs)
field: $ => choice(
seq(
field('name', $.dotted_path),
':',
field('value', $.value)
),
$.prose_block
),
dotted_path: $ => sep1($.identifier, '.'),
// Values
value: $ => choice(
$.integer,
$.float,
$.string,
$.boolean,
$.range,
$.time,
$.duration,
$.path,
$.prose_block,
$.list,
$.object,
$.override
),
integer: $ => /-?[0-9]+/,
float: $ => /-?[0-9]+\.[0-9]+/,
string: $ => /"([^"\\]|\\.)*"/,
boolean: $ => choice('true', 'false'),
range: $ => choice(
seq($.integer, '..', $.integer),
seq($.float, '..', $.float)
),
time: $ => /[0-9]{2}:[0-9]{2}(:[0-9]{2})?/,
duration: $ => /[0-9]+[dhms]([0-9]+[dhms])*/,
list: $ => seq('[', commaSep($.value), ']'),
object: $ => $.block,
block: $ => seq('{', repeat($.field), '}'),
// Override (@base { remove field, field: value })
override: $ => seq(
'@',
$.path,
'{',
repeat($.override_op),
'}'
),
override_op: $ => choice(
seq('remove', $.identifier),
seq('append', $.field),
$.field
),
// Prose blocks (---tag content ---)
prose_block: $ => seq(
field('marker', $.prose_marker),
field('tag', $.identifier),
optional(/[^\n]*/), // Rest of opening line
field('content', $.prose_content),
field('end', $.prose_marker)
),
prose_marker: $ => '---',
// Capture prose content as a single token for markdown injection
prose_content: $ => token(prec(-1, repeat1(choice(
/[^\-]+/, // Any non-dash characters
/-[^\-]/, // Single dash not followed by another dash
/-\-[^\-]/, // Two dashes not followed by another dash
)))),
// Life arc declaration
life_arc: $ => seq(
'life_arc',
field('name', $.identifier),
'{',
repeat($.field),
repeat($.arc_state),
'}'
),
arc_state: $ => seq(
'state',
field('name', $.identifier),
'{',
optional($.on_enter),
repeat($.field),
repeat($.transition),
'}'
),
on_enter: $ => seq('on', 'enter', $.block),
transition: $ => seq(
'on',
field('condition', $.expression),
'->',
field('target', $.identifier)
),
// Schedule declaration
schedule: $ => seq(
'schedule',
field('name', $.identifier),
'{',
repeat($.field),
repeat($.schedule_block),
'}'
),
schedule_block: $ => seq(
field('start', $.time),
'->',
field('end', $.time),
':',
field('activity', $.identifier),
$.block
),
// Behavior tree declaration
behavior: $ => seq(
'behavior',
field('name', $.identifier),
'{',
repeat($.field),
field('root', $.behavior_node),
'}'
),
behavior_node: $ => choice(
$.selector_node,
$.sequence_node,
$.condition_node,
$.if_decorator_node,
$.decorator_node,
$.action_node,
$.subtree_node
),
// Selector node: choose { ... }
selector_node: $ => seq(
'choose',
optional(field('label', $.identifier)),
'{',
repeat1($.behavior_node),
'}'
),
// Sequence node: then { ... }
sequence_node: $ => seq(
'then',
optional(field('label', $.identifier)),
'{',
repeat1($.behavior_node),
'}'
),
// Condition node: if(expr) or when(expr) - NO BRACES
condition_node: $ => seq(
choice('if', 'when'),
'(',
field('condition', $.expression),
')'
),
// If decorator: if(expr) { child } - WITH BRACES
if_decorator_node: $ => seq(
'if',
'(',
field('condition', $.expression),
')',
'{',
field('child', $.behavior_node),
'}'
),
// Decorator node: repeat/retry/timeout/etc { child }
decorator_node: $ => seq(
field('decorator', $.decorator_keyword),
optional(field('params', $.decorator_params)),
'{',
field('child', $.behavior_node),
'}'
),
decorator_keyword: $ => choice(
'repeat',
'invert',
'retry',
'timeout',
'cooldown',
'succeed_always',
'fail_always'
),
decorator_params: $ => seq(
'(',
choice(
// min..max range (for repeat)
seq($.integer, '..', $.integer),
// N (for repeat, retry)
$.integer,
// duration (for timeout, cooldown)
$.duration
),
')'
),
// Action node: action_name or action_name(params)
action_node: $ => choice(
seq($.identifier, '(', commaSep($.action_param), ')'),
$.identifier
),
action_param: $ => choice(
// Named parameter: name: value
seq($.dotted_path, ':', $.value),
// Positional parameter: just value
$.value
),
// Subtree node: include path::to::subtree
subtree_node: $ => seq('include', $.path),
// Institution declaration
institution: $ => seq(
'institution',
field('name', $.identifier),
$.block
),
// Relationship declaration
relationship: $ => seq(
'relationship',
field('name', $.identifier),
'{',
repeat1($.participant),
repeat($.field),
'}'
),
participant: $ => choice(
// name as role { fields } (role optional)
seq($.path, 'as', $.identifier, $.block),
// name { fields } (block required)
seq($.path, $.block)
),
// Location declaration
location: $ => seq(
'location',
field('name', $.identifier),
$.block
),
// Species declaration
species: $ => seq(
'species',
field('name', $.identifier),
'{',
repeat($.include),
repeat($.field),
'}'
),
// Enum declaration
enum_declaration: $ => seq(
'enum',
field('name', $.identifier),
'{',
commaSep1($.identifier),
'}'
),
// Expressions (for conditions in life arcs)
expression: $ => choice(
$.or_expression,
$.and_expression,
$.not_expression,
$.comparison,
$.field_access,
$.primary_expression
),
or_expression: $ => prec.left(1, seq(
$.expression,
'or',
$.expression
)),
and_expression: $ => prec.left(2, seq(
$.expression,
'and',
$.expression
)),
not_expression: $ => prec(3, seq('not', $.expression)),
comparison: $ => prec.left(4, seq(
$.expression,
field('operator', choice('is', '>', '>=', '<', '<=')),
$.expression
)),
field_access: $ => prec.left(5, seq(
$.expression,
'.',
$.identifier
)),
primary_expression: $ => choice(
'self',
'other',
$.integer,
$.float,
$.string,
$.boolean,
$.path
),
// Identifiers
identifier: $ => /[a-zA-Z_][a-zA-Z0-9_]*/,
}
});
/**
* Helper to create comma-separated lists with optional trailing comma
*/
function commaSep(rule) {
return optional(commaSep1(rule));
}
function commaSep1(rule) {
return seq(rule, repeat(seq(',', rule)), optional(','));
}
/**
* Helper to create separator-based lists (e.g., :: or .)
*/
function sep1(rule, separator) {
return seq(rule, repeat(seq(separator, rule)));
}