///|
priv struct ParserState {
blocks : Array[HostBlock]
items : Array[ConfigItem]
mut current_directives : Array[Directive]
}
///|
fn new_parser_state(path : String) -> ParserState {
let origin = location(path, 1, 1)
let global_directives : Array[Directive] = []
let global_block : HostBlock = {
patterns: ["*"],
directives: global_directives,
location: origin,
span: span_from(origin, origin),
}
{
blocks: [global_block],
items: [Global(global_directives)],
current_directives: global_directives,
}
}
///|
fn config_from(state : ParserState) -> Config {
{ items: state.items, blocks: state.blocks }
}
///|
fn line_end(tokens : Array[Lexeme]) -> SourceLocation {
match tokens.last() {
Some(token) => token.span.end_
None => { path: "", line: 1, column: 1 }
}
}
///|
fn characters_in(value : String) -> Int {
value.to_array().length()
}
///|
fn split_keyword(token : Lexeme) -> (String, String?, SourceSpan?) {
match token.value.split_once("=") {
Some((left, right)) if !left.is_empty() => {
let keyword = left.to_owned()
let value = right.to_owned()
let value_start : SourceLocation = {
path: token.span.start.path,
line: token.span.start.line,
column: token.span.start.column + characters_in(keyword) + 1,
}
(keyword, Some(value), Some(span_from(value_start, token.span.end_)))
}
_ => (token.value, None, None)
}
}
///|
fn strip_equals_separator(
tokens : Array[Lexeme],
inline_value : String?,
inline_span : SourceSpan?,
) -> (Array[Lexeme], String?, SourceSpan?) {
if inline_value is Some(_) || tokens.is_empty() {
return (tokens, inline_value, inline_span)
}
let first = tokens[0]
if !first.value.has_prefix("=") {
return (tokens, inline_value, inline_span)
}
let value = first.value[1:].to_owned()
let value_start : SourceLocation = {
path: first.span.start.path,
line: first.span.start.line,
column: first.span.start.column + 1,
}
(
tokens[1:].to_owned(),
Some(value),
Some(span_from(value_start, first.span.end_)),
)
}
///|
fn make_arguments(
tokens : Array[Lexeme],
inline_value : String?,
inline_span : SourceSpan?,
) -> (Array[String], Array[Argument]) {
let values : Array[String] = []
let infos : Array[Argument] = []
match inline_value {
Some(value) if !value.is_empty() =>
match inline_span {
Some(span) => {
values.push(value)
infos.push({ value, span })
}
None => ()
}
_ => ()
}
for token in tokens {
values.push(token.value)
infos.push({ value: token.value, span: token.span })
}
(values, infos)
}
///|
fn add_line(
state : ParserState,
tokens : Array[Lexeme],
raw_line : String,
) -> Unit raise ParseError {
guard tokens[0] is first else { return }
let (raw_keyword, inline_value, inline_span) = split_keyword(first)
let keyword = lower_ascii(raw_keyword)
let (rest, normalized_inline_value, normalized_inline_span) = strip_equals_separator(
tokens[1:].to_owned(),
inline_value,
inline_span,
)
let (arguments, argument_infos) = make_arguments(
rest, normalized_inline_value, normalized_inline_span,
)
let location = first.span.start
let span = span_from(location, line_end(tokens))
if arguments.is_empty() || arguments[0].is_empty() {
raise MissingArgument(location~, keyword~)
}
if keyword == "host" {
if arguments[0].is_empty() {
raise InvalidBlockHeader(location~, keyword~)
}
let directives : Array[Directive] = []
let block : HostBlock = { patterns: arguments, directives, location, span }
state.blocks.push(block)
state.items.push(Host(block))
state.current_directives = directives
return
}
if keyword == "match" {
if arguments[0].is_empty() {
raise InvalidBlockHeader(location~, keyword~)
}
let directives : Array[Directive] = []
let block : MatchBlock = {
conditions: arguments,
directives,
location,
span,
}
state.items.push(Match(block))
state.current_directives = directives
return
}
state.current_directives.push({
keyword,
arguments,
argument_infos,
location,
span,
raw: Some(raw_line),
})
}
///|
fn remove_terminal_carriage_return(raw_line : String) -> String {
if raw_line.has_suffix("\r") {
raw_line.unsafe_substring(start=0, end=raw_line.length() - 1)
} else {
raw_line
}
}
///|
fn diagnostic_for(error : ParseError) -> Diagnostic {
match error {
MissingArgument(location~, ..) =>
{ error, location, summary: "directive requires at least one argument" }
UnterminatedQuote(location~) =>
{ error, location, summary: "unterminated quoted argument" }
DanglingEscape(location~) =>
{ error, location, summary: "escape has no following character" }
UnexpectedNul(location~) =>
{ error, location, summary: "NUL is not valid in SSH configuration" }
LineTooLong(location~, ..) =>
{ error, location, summary: "configuration line exceeds parser limit" }
SourceTooLarge(location~, ..) =>
{ error, location, summary: "configuration source exceeds parser limit" }
InvalidBlockHeader(location~, ..) =>
{ error, location, summary: "invalid section header" }
InvalidDirective(location~, ..) =>
{ error, location, summary: "invalid configuration syntax" }
}
}
///|
/// Parse OpenSSH client configuration syntax strictly.
///
/// The returned config preserves input-order items and source spans. Global
/// directives are represented by the leading normalized `ConfigItem::Global`.
pub fn parse(
source : String,
path? : String = "",
) -> Config raise ParseError {
if source_exceeds_byte_limit(source) {
raise SourceTooLarge(location=location(path, 1, 1), limit=max_source_bytes)
}
let state = new_parser_state(path)
for line_index, raw_view in source.split("\n") {
let raw_line = remove_terminal_carriage_return(raw_view.to_owned())
let tokens = lex_line(raw_line, path, line_index + 1)
if !tokens.is_empty() {
add_line(state, tokens, raw_line)
}
}
config_from(state)
}
///|
/// Parse configuration while collecting per-line syntax diagnostics.
///
/// Recovery skips the offending line and continues with the following line,
/// making this entry point suitable for linting and editor integrations.
pub fn parse_recovering(
source : String,
path? : String = "",
) -> ParseResult {
let state = new_parser_state(path)
let diagnostics : Array[Diagnostic] = []
if source_exceeds_byte_limit(source) {
diagnostics.push(
diagnostic_for(
SourceTooLarge(location=location(path, 1, 1), limit=max_source_bytes),
),
)
return { config: config_from(state), diagnostics }
}
for line_index, raw_view in source.split("\n") {
let raw_line = remove_terminal_carriage_return(raw_view.to_owned())
let tokens = lex_line(raw_line, path, line_index + 1) catch {
error => {
diagnostics.push(diagnostic_for(error))
[]
}
}
if !tokens.is_empty() {
add_line(state, tokens, raw_line) catch {
error => diagnostics.push(diagnostic_for(error))
}
}
}
{ config: config_from(state), diagnostics }
}