diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-06-25 14:39:00 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-06-25 23:47:47 +0900 |
| commit | d0336078c5b63b174e7313d54d973a1832228928 (patch) | |
| tree | b0ed23c382e7ffd854fd3afb266a6932002e3335 | |
| parent | eebba7ebad103a2f7afe885a25ba2e96efddbd89 (diff) | |
| download | php-shirabe-d0336078c5b63b174e7313d54d973a1832228928.tar.gz php-shirabe-d0336078c5b63b174e7313d54d973a1832228928.tar.zst php-shirabe-d0336078c5b63b174e7313d54d973a1832228928.zip | |
feat(external-packages,shim): implement impl todos across components
Port seld/jsonlint JsonParser (+hand-written Lexer), unblocking 10 json_file
parse-error tests verified byte-for-byte against PHP. Implement Symfony Finder
SplFileInfo, executable finders, String classes (byte/code-point/unicode),
ZipArchive shim (via the zip crate), SPDX license validation, and shim
date/stream functions. Genuinely-blocked sites (reflection, PHP runtime
constants, non-UTF-8 transcoding, recursive PCRE) stay todo!() with reasons.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
18 files changed, 2312 insertions, 84 deletions
@@ -1995,6 +1995,8 @@ dependencies = [ name = "shirabe-spdx-licenses" version = "0.0.1" dependencies = [ + "indexmap", + "serde_json", "shirabe-php-shim", ] diff --git a/crates/shirabe-external-packages/src/seld/json_lint/json_parser.rs b/crates/shirabe-external-packages/src/seld/json_lint/json_parser.rs index 193a208..6607938 100644 --- a/crates/shirabe-external-packages/src/seld/json_lint/json_parser.rs +++ b/crates/shirabe-external-packages/src/seld/json_lint/json_parser.rs @@ -1,9 +1,71 @@ //! ref: composer/vendor/seld/jsonlint/src/Seld/JsonLint/JsonParser.php +use std::collections::HashMap; + +use indexmap::IndexMap; use shirabe_php_shim::PhpMixed; +use super::DuplicateKeyException; +use super::ParsingException; +use super::lexer::{Lexer, YylLoc}; + +/// Semantic value held on the parser value stack ($vstack). Most values are JSON values (PhpMixed), +/// but object members are represented as a `[key, value]` pair while being reduced. +#[derive(Debug, Clone)] +enum SemValue { + Null, + Value(PhpMixed), + /// Raw token text from the lexer (yytext), before semantic actions transform it. + Text(String), + /// A `[key, value]` member pair (production 15). + Member(String, PhpMixed), +} + +impl SemValue { + fn into_value(self) -> PhpMixed { + match self { + SemValue::Null => PhpMixed::Null, + SemValue::Value(v) => v, + SemValue::Text(s) => PhpMixed::String(s), + SemValue::Member(_, _) => PhpMixed::Null, + } + } +} + +/// Result of `performAction`: the new semantic value plus an optional early-return (production 6). +enum ActionResult { + /// `$$` was assigned but parsing continues (PHP returns `[token, new Undefined()]`). + Continue(SemValue), + /// Production 6 returned the accumulated value directly. + Return(PhpMixed), +} + +#[derive(Debug)] +pub struct JsonParser { + productions_: HashMap<i64, (i64, i64)>, + terminals_: HashMap<i64, &'static str>, + table: Vec<HashMap<i64, TableAction>>, + default_actions: HashMap<i64, (i64, i64)>, +} + +/// Per-parse mutable state. PHP keeps `$flags`/`$stack`/`$vstack`/`$lstack` on the parser instance; +/// holding them here keeps `parse()` `&self` so the parser can be shared/reused. #[derive(Debug)] -pub struct JsonParser; +struct ParseState { + flags: u32, + stack: Vec<i64>, + vstack: Vec<SemValue>, + lstack: Vec<YylLoc>, +} + +/// An entry in the parse table: either a shift/reduce action pair `[type, arg]` or a goto state. +#[derive(Debug, Clone, Copy)] +enum TableAction { + /// `array(type, arg)` — type 1 = shift, 2 = reduce, 3 = accept. + Action(i64, i64), + /// A bare integer = goto state (used for nonterminals). + Goto(i64), +} impl Default for JsonParser { fn default() -> Self { @@ -13,17 +75,972 @@ impl Default for JsonParser { impl JsonParser { pub const DETECT_KEY_CONFLICTS: u32 = 1; + pub const ALLOW_DUPLICATE_KEYS: u32 = 2; + pub const PARSE_TO_ASSOC: u32 = 4; + pub const ALLOW_COMMENTS: u32 = 8; + pub const ALLOW_DUPLICATE_KEYS_TO_ARRAY: u32 = 16; pub fn new() -> Self { - todo!() + let productions_: HashMap<i64, (i64, i64)> = [ + (1, (3, 1)), + (2, (5, 1)), + (3, (7, 1)), + (4, (9, 1)), + (5, (9, 1)), + (6, (12, 2)), + (7, (13, 1)), + (8, (13, 1)), + (9, (13, 1)), + (10, (13, 1)), + (11, (13, 1)), + (12, (13, 1)), + (13, (15, 2)), + (14, (15, 3)), + (15, (20, 3)), + (16, (19, 1)), + (17, (19, 3)), + (18, (16, 2)), + (19, (16, 3)), + (20, (25, 1)), + (21, (25, 3)), + ] + .into_iter() + .collect(); + + let terminals_: HashMap<i64, &'static str> = [ + (2, "error"), + (4, "STRING"), + (6, "NUMBER"), + (8, "NULL"), + (10, "TRUE"), + (11, "FALSE"), + (14, "EOF"), + (17, "{"), + (18, "}"), + (21, ":"), + (22, ","), + (23, "["), + (24, "]"), + ] + .into_iter() + .collect(); + + let table = build_table(); + + let default_actions: HashMap<i64, (i64, i64)> = [(16, (2, 6))].into_iter().collect(); + + Self { + productions_, + terminals_, + table, + default_actions, + } + } + + /// ref: JsonParser::lint() — returns null on success, ParsingException on failure. + pub fn lint(&mut self, input: &str) -> Option<ParsingException> { + match self.parse(input, 0) { + Ok(_) => None, + Err(e) => match e.downcast::<ParsingException>() { + Ok(pe) => Some(pe), + // PHP only catches ParsingException; DuplicateKeyException (a subclass) is also + // caught, but lint() uses no flags so DETECT_KEY_CONFLICTS never triggers. + Err(other) => Some(ParsingException::new(other.to_string(), Default::default())), + }, + } + } + + pub fn parse(&self, input: &str, flags: u32) -> anyhow::Result<PhpMixed> { + if (flags & Self::ALLOW_DUPLICATE_KEYS_TO_ARRAY != 0) + && (flags & Self::ALLOW_DUPLICATE_KEYS != 0) + { + // PHP throws \InvalidArgumentException (uncaught fatal). + panic!( + "Only one of ALLOW_DUPLICATE_KEYS and ALLOW_DUPLICATE_KEYS_TO_ARRAY can be used, you passed in both." + ); + } + + self.fail_on_bom(input)?; + + let mut state_ = ParseState { + flags, + stack: vec![0], + vstack: vec![SemValue::Null], + lstack: Vec::new(), + }; + + let mut yytext = String::new(); + let mut yylineno: i64 = 0; + let mut yyleng: i64 = 0; + let mut recovering: i64 = 0; + + let mut lexer = Lexer::new(flags); + lexer.set_input(input); + + let mut yyloc = lexer.yylloc.clone(); + state_.lstack.push(yyloc.clone()); + + let mut symbol: Option<i64> = None; + let mut pre_error_symbol: Option<i64> = None; + let mut err_str: Option<String> = None; + + loop { + let mut state = state_.stack[state_.stack.len() - 1]; + + // use default actions if available + let mut action: Option<(i64, i64)> = if let Some(a) = self.default_actions.get(&state) { + Some(*a) + } else { + if symbol.is_none() { + symbol = Some(lexer.lex()?); + } + self.table[state as usize] + .get(&symbol.unwrap()) + .and_then(|a| match *a { + TableAction::Action(t, arg) => Some((t, arg)), + TableAction::Goto(_) => None, + }) + }; + + // handle parse error + if action.is_none() || action.unwrap().0 == 0 { + let sym = symbol.expect("symbol should be set"); + if recovering == 0 { + // PHP iterates table entries in insertion order; mimic by sorting on symbol id. + let mut expected_pairs: Vec<(i64, String)> = self.table[state as usize] + .keys() + .filter_map(|p| { + self.terminals_ + .get(p) + .filter(|_| *p > 2) + .map(|name| (*p, format!("'{name}'"))) + }) + .collect(); + expected_pairs.sort_by_key(|(p, _)| *p); + let expected: Vec<String> = + expected_pairs.into_iter().map(|(_, s)| s).collect(); + + let mut message: Option<String> = None; + let match_first = lexer.match_.first().copied(); + if expected.iter().any(|e| e == "'STRING'") + && matches!(match_first, Some(b'"') | Some(b'\'')) + { + let mut msg = String::from("Invalid string"); + if match_first == Some(b'\'') { + msg.push_str( + ", it appears you used single quotes instead of double quotes", + ); + } else if let Some(found) = + detect_unescaped_backslash(&lexer.get_full_upcoming_input()) + { + msg.push_str(", it appears you have an unescaped backslash at: "); + msg.push_str(&found); + } else if detect_unterminated_string(&lexer.get_full_upcoming_input()) { + msg.push_str(", it appears you forgot to terminate a string, or attempted to write a multiline string which is invalid"); + } + message = Some(msg); + } + + let mut errstr = format!("Parse error on line {}:\n", yylineno + 1); + errstr.push_str(&lexer.show_position()); + errstr.push('\n'); + if let Some(msg) = &message { + errstr.push_str(msg); + } else { + errstr.push_str(if expected.len() > 1 { + "Expected one of: " + } else { + "Expected: " + }); + errstr.push_str(&expected.join(", ")); + } + + let past = lexer.get_past_input(); + let trimmed = trim_bytes(&past); + if trimmed.last() == Some(&b',') { + errstr.push_str(" - It appears you have an extra trailing comma"); + } + + err_str = Some(errstr.clone()); + + let token = if let Some(name) = self.terminals_.get(&sym) { + super::parsing_exception::ParsingExceptionToken::Name(name.to_string()) + } else { + super::parsing_exception::ParsingExceptionToken::Symbol(sym) + }; + let details = super::parsing_exception::ParsingExceptionDetails { + text: Some(String::from_utf8_lossy(&lexer.match_).into_owned()), + token: Some(token), + line: Some(lexer.yylineno), + loc: Some(yyloc_to_loc(&yyloc)), + expected: Some(expected.clone()), + }; + return Err(ParsingException::new(errstr, details).into()); + } + + // recovery path (recovering != 0). Not reachable for this grammar because the error + // is always reported (and thrown) above on the first failure; kept for fidelity. + if recovering == 3 { + if sym == Lexer::EOF { + return Err(ParsingException::new( + err_str.clone().unwrap_or_else(|| "Parsing halted.".into()), + Default::default(), + ) + .into()); + } + yyleng = lexer.yyleng; + yytext = String::from_utf8_lossy(&lexer.yytext).into_owned(); + yylineno = lexer.yylineno; + yyloc = lexer.yylloc.clone(); + symbol = Some(lexer.lex()?); + } + + loop { + if self.table[state as usize].contains_key(&Lexer::T_ERROR) { + break; + } + if state == 0 { + return Err(ParsingException::new( + err_str.clone().unwrap_or_else(|| "Parsing halted.".into()), + Default::default(), + ) + .into()); + } + state_.pop_stack(1); + state = state_.stack[state_.stack.len() - 1]; + } + + pre_error_symbol = symbol; + symbol = Some(Lexer::T_ERROR); + state = state_.stack[state_.stack.len() - 1]; + action = self.table[state as usize] + .get(&Lexer::T_ERROR) + .and_then(|a| match *a { + TableAction::Action(t, arg) => Some((t, arg)), + TableAction::Goto(_) => None, + }); + if action.is_none() { + panic!("No table value found for {} => {}", state, Lexer::T_ERROR); + } + recovering = 3; + } + + let action = action.unwrap(); + match action.0 { + 1 => { + // shift + let sym = symbol.expect("symbol should be set"); + state_.stack.push(sym); + state_.vstack.push(SemValue::Text( + String::from_utf8_lossy(&lexer.yytext).into_owned(), + )); + state_.lstack.push(lexer.yylloc.clone()); + state_.stack.push(action.1); + symbol = None; + if pre_error_symbol.is_none() { + yyleng = lexer.yyleng; + yytext = String::from_utf8_lossy(&lexer.yytext).into_owned(); + yylineno = lexer.yylineno; + yyloc = lexer.yylloc.clone(); + if recovering > 0 { + recovering -= 1; + } + } else { + symbol = pre_error_symbol; + pre_error_symbol = None; + } + } + 2 => { + // reduce + let prod = self.productions_[&action.1]; + let len = prod.1; + + let position = YylLoc { + first_line: state_.lstack[state_.lstack.len() - (len.max(1) as usize)] + .first_line, + last_line: state_.lstack[state_.lstack.len() - 1].last_line, + first_column: state_.lstack[state_.lstack.len() - (len.max(1) as usize)] + .first_column, + last_column: state_.lstack[state_.lstack.len() - 1].last_column, + }; + + match state_.perform_action(&yytext, yyleng, yylineno, action.1)? { + ActionResult::Return(v) => return Ok(v), + ActionResult::Continue(new_token) => { + if len != 0 { + state_.pop_stack(len); + } + state_.stack.push(prod.0); + state_.vstack.push(new_token); + state_.lstack.push(position); + let goto_state = match self.table + [state_.stack[state_.stack.len() - 2] as usize] + .get(&state_.stack[state_.stack.len() - 1]) + { + Some(TableAction::Goto(s)) => *s, + Some(TableAction::Action(_, arg)) => *arg, + None => panic!("No goto state"), + }; + state_.stack.push(goto_state); + } + } + } + 3 => { + // accept + return Ok(PhpMixed::Bool(true)); + } + _ => {} + } + } + } + + fn fail_on_bom(&self, input: &str) -> Result<(), ParsingException> { + let bom = [0xEF, 0xBB, 0xBF]; + if input.as_bytes().len() >= 3 && input.as_bytes()[0..3] == bom { + return Err(ParsingException::new( + "BOM detected, make sure your input does not include a Unicode Byte-Order-Mark" + .to_string(), + Default::default(), + )); + } + Ok(()) + } +} + +impl ParseState { + /// ref: JsonParser::performAction + fn perform_action( + &mut self, + _yytext: &str, + _yyleng: i64, + yylineno: i64, + yystate: i64, + ) -> anyhow::Result<ActionResult> { + // `$len = count($vstack) - 1` indexes the top semantic value. + let len = self.vstack.len() - 1; + // default: $$ = $1, the value at (count - production_len) ; PHP captures `$currentToken` + // separately, but for productions we rebuild explicitly below, defaulting to the top value. + let mut token: SemValue = self.vstack[len].clone(); + + match yystate { + 1 => { + // string interpolation of escape sequences + let raw = match &self.vstack[len] { + SemValue::Text(s) => s.clone(), + other => semvalue_string(other), + }; + token = SemValue::Value(PhpMixed::String(string_interpolation_all(&raw))); + } + 2 => { + let raw = match &self.vstack[len] { + SemValue::Text(s) => s.clone(), + other => semvalue_string(other), + }; + let v = if raw.contains('e') || raw.contains('E') { + PhpMixed::Float(php_floatval(&raw)) + } else if !raw.contains('.') { + PhpMixed::Int(php_intval(&raw)) + } else { + PhpMixed::Float(php_floatval(&raw)) + }; + token = SemValue::Value(v); + } + 3 => token = SemValue::Value(PhpMixed::Null), + 4 => token = SemValue::Value(PhpMixed::Bool(true)), + 5 => token = SemValue::Value(PhpMixed::Bool(false)), + 6 => { + let v = self.vstack[len - 1].clone().into_value(); + return Ok(ActionResult::Return(v)); + } + 13 => { + let v = if self.flags & JsonParser::PARSE_TO_ASSOC != 0 { + PhpMixed::Array(IndexMap::new()) + } else { + PhpMixed::Object(IndexMap::new()) + }; + token = SemValue::Value(v); + } + 14 => { + token = SemValue::Value(self.vstack[len - 1].clone().into_value()); + } + 15 => { + let key = match self.vstack[len - 2].clone() { + SemValue::Value(PhpMixed::String(s)) => s, + SemValue::Text(s) => s, + other => semvalue_string(&other), + }; + let value = self.vstack[len].clone().into_value(); + token = SemValue::Member(key, value); + } + 16 => { + let (property, value) = match self.vstack[len].clone() { + SemValue::Member(k, v) => (k, v), + _ => panic!("expected member pair"), + }; + let mut map = IndexMap::new(); + map.insert(property, value); + token = if self.flags & JsonParser::PARSE_TO_ASSOC != 0 { + SemValue::Value(PhpMixed::Array(map)) + } else { + SemValue::Value(PhpMixed::Object(map)) + }; + } + 17 => { + let (key, value) = match self.vstack[len].clone() { + SemValue::Member(k, v) => (k, v), + _ => panic!("expected member pair"), + }; + let mut container = self.vstack[len - 2].clone().into_value(); + let map = match &mut container { + PhpMixed::Array(m) | PhpMixed::Object(m) => m, + _ => panic!("expected object/array container"), + }; + + if (self.flags & JsonParser::DETECT_KEY_CONFLICTS != 0) && map.contains_key(&key) { + // PHP inserts $this->lexer->showPosition() here; the lexer is owned by parse() + // and not reachable from this method, so the position line is omitted. Only the + // "Duplicate key" body is read by ConfigValidator (DETECT_KEY_CONFLICTS). + let mut errstr = format!("Parse error on line {}:\n", yylineno + 1); + errstr.push('\n'); + errstr.push_str(&format!("Duplicate key: {key}")); + let mut details: IndexMap<String, PhpMixed> = IndexMap::new(); + // PHP details: array('line' => $yylineno+1); the 'key' entry is read by + // ConfigValidator, so include it as well (PHP DuplicateKeyException stores the + // key separately, but Shirabe's details map carries both). + details.insert("key".to_string(), PhpMixed::String(key.clone())); + details.insert("line".to_string(), PhpMixed::Int(yylineno + 1)); + return Err(DuplicateKeyException { + message: errstr, + code: 0, + details, + } + .into()); + } + + if (self.flags & JsonParser::ALLOW_DUPLICATE_KEYS != 0) && map.contains_key(&key) { + let mut duplicate_count = 1; + let mut duplicate_key; + loop { + duplicate_key = format!("{key}.{duplicate_count}"); + duplicate_count += 1; + if !map.contains_key(&duplicate_key) { + break; + } + } + map.insert(duplicate_key, value); + } else { + // ALLOW_DUPLICATE_KEYS_TO_ARRAY path omitted: requires `__duplicates__` nesting; + // unused by Composer's lint/parse defaults. + map.insert(key, value); + } + token = SemValue::Value(container); + } + 18 => token = SemValue::Value(PhpMixed::List(Vec::new())), + 19 => { + token = SemValue::Value(self.vstack[len - 1].clone().into_value()); + } + 20 => { + let v = self.vstack[len].clone().into_value(); + token = SemValue::Value(PhpMixed::List(vec![v])); + } + 21 => { + let mut list = self.vstack[len - 2].clone().into_value(); + let v = self.vstack[len].clone().into_value(); + if let PhpMixed::List(items) = &mut list { + items.push(v); + } else { + panic!("expected list container"); + } + token = SemValue::Value(list); + } + _ => {} + } + + Ok(ActionResult::Continue(token)) + } + + fn pop_stack(&mut self, n: i64) { + let n = n as usize; + let new_stack_len = self.stack.len() - 2 * n; + self.stack.truncate(new_stack_len); + let new_v = self.vstack.len() - n; + self.vstack.truncate(new_v); + let new_l = self.lstack.len() - n; + self.lstack.truncate(new_l); + } +} + +fn yyloc_to_loc(loc: &YylLoc) -> super::parsing_exception::ParsingExceptionLoc { + super::parsing_exception::ParsingExceptionLoc { + first_line: loc.first_line, + first_column: loc.first_column, + last_line: loc.last_line, + last_column: loc.last_column, + } +} + +fn semvalue_string(v: &SemValue) -> String { + match v { + SemValue::Text(s) => s.clone(), + SemValue::Value(PhpMixed::String(s)) => s.clone(), + _ => String::new(), } +} + +/// ref: JsonParser::stringInterpolation, applied to every escape sequence in the string +/// (PHP uses preg_replace_callback with '{(?:\\["bfnrt/\\]|\\u[a-fA-F0-9]{4})}'). +fn string_interpolation_all(input: &str) -> String { + let bytes = input.as_bytes(); + let mut out: Vec<u8> = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'\\' && i + 1 < bytes.len() { + let n = bytes[i + 1]; + match n { + b'\\' => { + out.push(b'\\'); + i += 2; + continue; + } + b'"' => { + out.push(b'"'); + i += 2; + continue; + } + b'b' => { + out.push(8); + i += 2; + continue; + } + b'f' => { + out.push(12); + i += 2; + continue; + } + b'n' => { + out.push(b'\n'); + i += 2; + continue; + } + b'r' => { + out.push(b'\r'); + i += 2; + continue; + } + b't' => { + out.push(b'\t'); + i += 2; + continue; + } + b'/' => { + out.push(b'/'); + i += 2; + continue; + } + b'u' if i + 5 < bytes.len() + && bytes[i + 2..i + 6].iter().all(|b| b.is_ascii_hexdigit()) => + { + let hex = &input[i + 2..i + 6]; + if let Ok(cp) = u32::from_str_radix(hex, 16) { + if let Some(c) = char::from_u32(cp) { + let mut buf = [0u8; 4]; + out.extend_from_slice(c.encode_utf8(&mut buf).as_bytes()); + } + } + i += 6; + continue; + } + _ => {} + } + } + out.push(bytes[i]); + i += 1; + } + String::from_utf8_lossy(&out).into_owned() +} + +/// PHP floatval for a JSON number literal. +fn php_floatval(s: &str) -> f64 { + s.parse::<f64>().unwrap_or(0.0) +} + +/// PHP intval for a JSON integer literal. +fn php_intval(s: &str) -> i64 { + // PHP intval parses leading integer portion; JSON numbers here are already valid integers. + s.parse::<i64>().unwrap_or_else(|_| { + // overflow fallback: PHP would clamp/convert, but valid lexed ints rarely overflow i64. + if s.starts_with('-') { + i64::MIN + } else { + i64::MAX + } + }) +} + +/// ref: preg_match('{".+?(\\[^"bfnrt/\\u](...)?)}', $fullUpcomingInput) — returns the captured +/// group 1 (the unescaped backslash plus up to 3 following chars) if found. +fn detect_unescaped_backslash(input: &[u8]) -> Option<String> { + // Pattern: a `"`, then `.+?` (at least one char, non-greedy), then a `\` that is NOT followed by + // a valid escape char (one of "bfnrt/\\u), then optionally up to 3 more chars (the `(...)?`). + // `.` does not match newline. We scan for the first `"`, then for the first qualifying backslash + // after at least one character. + let valid_escape = |b: u8| { + matches!( + b, + b'"' | b'b' | b'f' | b'n' | b'r' | b't' | b'/' | b'\\' | b'u' + ) + }; + let n = input.len(); + // find first `"` + let mut q = 0; + while q < n { + if input[q] == b'"' && input[q] != b'\n' { + break; + } + q += 1; + } + if q >= n || input[q] != b'"' { + return None; + } + // need at least one char (.+?) after the quote, then the backslash. + // The `.+?` must match at least one non-newline char before the backslash. + let mut i = q + 1; + // ensure at least one char consumed by .+? : the backslash cannot be the char immediately + // following the quote? `.+?` is greedy-minimal but must consume >=1, and `.` excludes newline. + let mut consumed_one = false; + while i < n { + let c = input[i]; + if c == b'\n' { + // `.` cannot cross a newline; PCRE `.+?` would stop — no match across lines here. + return None; + } + if c == b'\\' { + if consumed_one { + let next = input.get(i + 1).copied(); + let bad = match next { + None => true, // `[^...]` requires a char; if none, no match + Some(b) => !valid_escape(b) && b != b'\n', + }; + if next.is_some() && bad { + // capture group 1: the `\`, the disallowed char, then up to 3 more (...)? + let start = i; + let mut end = i + 2; // backslash + the [^...] char + // (...)? = exactly 3 chars if present + let mut extra = 0; + let mut k = end; + while extra < 3 && k < n && input[k] != b'\n' { + k += 1; + extra += 1; + } + if extra == 3 { + end = k; + } + return Some(String::from_utf8_lossy(&input[start..end]).into_owned()); + } + } + } + consumed_one = true; + i += 1; + } + None +} - pub fn parse(&self, _json: &str, _flags: u32) -> anyhow::Result<PhpMixed> { - todo!() +/// ref: preg_match('{"(?:[^"]+|\\")*$}m', $fullUpcomingInput) — a `"` followed by string body that +/// reaches a line end without a closing quote (multiline, with `m` so `$` is end-of-line). +fn detect_unterminated_string(input: &[u8]) -> bool { + // Find a `"`; from there consume `[^"]+ | \"` repeatedly; succeed if we reach end-of-line/string + // without hitting an unescaped closing `"`. + let n = input.len(); + let mut start = 0; + while start < n { + if input[start] == b'"' { + // try matching from this quote + let mut i = start + 1; + loop { + if i >= n { + return true; // reached end of string ($ at end) + } + let c = input[i]; + if c == b'\n' { + return true; // $ matches before newline in multiline mode + } + if c == b'\\' && input.get(i + 1) == Some(&b'"') { + i += 2; // \" alternative + continue; + } + if c == b'"' { + break; // closing quote: this start fails, try next `"` + } + i += 1; // [^"]+ + } + } + start += 1; } + false +} - /// PHP: JsonParser::lint() — returns null on success, ParsingException on failure. - pub fn lint(&mut self, _json: &str) -> Option<crate::seld::json_lint::ParsingException> { - todo!() +fn trim_bytes(input: &[u8]) -> Vec<u8> { + // PHP trim() default strips " \t\n\r\0\x0B" + let is_trim = |b: u8| matches!(b, b' ' | b'\t' | b'\n' | b'\r' | 0 | 0x0b); + let mut start = 0; + let mut end = input.len(); + while start < end && is_trim(input[start]) { + start += 1; + } + while end > start && is_trim(input[end - 1]) { + end -= 1; } + input[start..end].to_vec() +} + +/// Builds the LALR parse `table`. Indexed by state id (0..=31). +fn build_table() -> Vec<HashMap<i64, TableAction>> { + use TableAction::{Action, Goto}; + let mut t: Vec<HashMap<i64, TableAction>> = (0..32).map(|_| HashMap::new()).collect(); + + let mut set = |state: usize, entries: &[(i64, TableAction)]| { + for (sym, act) in entries { + t[state].insert(*sym, *act); + } + }; + + set( + 0, + &[ + (3, Goto(5)), + (4, Action(1, 12)), + (5, Goto(6)), + (6, Action(1, 13)), + (7, Goto(3)), + (8, Action(1, 9)), + (9, Goto(4)), + (10, Action(1, 10)), + (11, Action(1, 11)), + (12, Goto(1)), + (13, Goto(2)), + (15, Goto(7)), + (16, Goto(8)), + (17, Action(1, 14)), + (23, Action(1, 15)), + ], + ); + set(1, &[(1, Action(3, 0))]); + set(2, &[(14, Action(1, 16))]); + set( + 3, + &[ + (14, Action(2, 7)), + (18, Action(2, 7)), + (22, Action(2, 7)), + (24, Action(2, 7)), + ], + ); + set( + 4, + &[ + (14, Action(2, 8)), + (18, Action(2, 8)), + (22, Action(2, 8)), + (24, Action(2, 8)), + ], + ); + set( + 5, + &[ + (14, Action(2, 9)), + (18, Action(2, 9)), + (22, Action(2, 9)), + (24, Action(2, 9)), + ], + ); + set( + 6, + &[ + (14, Action(2, 10)), + (18, Action(2, 10)), + (22, Action(2, 10)), + (24, Action(2, 10)), + ], + ); + set( + 7, + &[ + (14, Action(2, 11)), + (18, Action(2, 11)), + (22, Action(2, 11)), + (24, Action(2, 11)), + ], + ); + set( + 8, + &[ + (14, Action(2, 12)), + (18, Action(2, 12)), + (22, Action(2, 12)), + (24, Action(2, 12)), + ], + ); + set( + 9, + &[ + (14, Action(2, 3)), + (18, Action(2, 3)), + (22, Action(2, 3)), + (24, Action(2, 3)), + ], + ); + set( + 10, + &[ + (14, Action(2, 4)), + (18, Action(2, 4)), + (22, Action(2, 4)), + (24, Action(2, 4)), + ], + ); + set( + 11, + &[ + (14, Action(2, 5)), + (18, Action(2, 5)), + (22, Action(2, 5)), + (24, Action(2, 5)), + ], + ); + set( + 12, + &[ + (14, Action(2, 1)), + (18, Action(2, 1)), + (21, Action(2, 1)), + (22, Action(2, 1)), + (24, Action(2, 1)), + ], + ); + set( + 13, + &[ + (14, Action(2, 2)), + (18, Action(2, 2)), + (22, Action(2, 2)), + (24, Action(2, 2)), + ], + ); + set( + 14, + &[ + (3, Goto(20)), + (4, Action(1, 12)), + (18, Action(1, 17)), + (19, Goto(18)), + (20, Goto(19)), + ], + ); + set( + 15, + &[ + (3, Goto(5)), + (4, Action(1, 12)), + (5, Goto(6)), + (6, Action(1, 13)), + (7, Goto(3)), + (8, Action(1, 9)), + (9, Goto(4)), + (10, Action(1, 10)), + (11, Action(1, 11)), + (13, Goto(23)), + (15, Goto(7)), + (16, Goto(8)), + (17, Action(1, 14)), + (23, Action(1, 15)), + (24, Action(1, 21)), + (25, Goto(22)), + ], + ); + set(16, &[(1, Action(2, 6))]); + set( + 17, + &[ + (14, Action(2, 13)), + (18, Action(2, 13)), + (22, Action(2, 13)), + (24, Action(2, 13)), + ], + ); + set(18, &[(18, Action(1, 24)), (22, Action(1, 25))]); + set(19, &[(18, Action(2, 16)), (22, Action(2, 16))]); + set(20, &[(21, Action(1, 26))]); + set( + 21, + &[ + (14, Action(2, 18)), + (18, Action(2, 18)), + (22, Action(2, 18)), + (24, Action(2, 18)), + ], + ); + set(22, &[(22, Action(1, 28)), (24, Action(1, 27))]); + set(23, &[(22, Action(2, 20)), (24, Action(2, 20))]); + set( + 24, + &[ + (14, Action(2, 14)), + (18, Action(2, 14)), + (22, Action(2, 14)), + (24, Action(2, 14)), + ], + ); + set(25, &[(3, Goto(20)), (4, Action(1, 12)), (20, Goto(29))]); + set( + 26, + &[ + (3, Goto(5)), + (4, Action(1, 12)), + (5, Goto(6)), + (6, Action(1, 13)), + (7, Goto(3)), + (8, Action(1, 9)), + (9, Goto(4)), + (10, Action(1, 10)), + (11, Action(1, 11)), + (13, Goto(30)), + (15, Goto(7)), + (16, Goto(8)), + (17, Action(1, 14)), + (23, Action(1, 15)), + ], + ); + set( + 27, + &[ + (14, Action(2, 19)), + (18, Action(2, 19)), + (22, Action(2, 19)), + (24, Action(2, 19)), + ], + ); + set( + 28, + &[ + (3, Goto(5)), + (4, Action(1, 12)), + (5, Goto(6)), + (6, Action(1, 13)), + (7, Goto(3)), + (8, Action(1, 9)), + (9, Goto(4)), + (10, Action(1, 10)), + (11, Action(1, 11)), + (13, Goto(31)), + (15, Goto(7)), + (16, Goto(8)), + (17, Action(1, 14)), + (23, Action(1, 15)), + ], + ); + set(29, &[(18, Action(2, 17)), (22, Action(2, 17))]); + set(30, &[(18, Action(2, 15)), (22, Action(2, 15))]); + set(31, &[(22, Action(2, 21)), (24, Action(2, 21))]); + + t } diff --git a/crates/shirabe-external-packages/src/seld/json_lint/lexer.rs b/crates/shirabe-external-packages/src/seld/json_lint/lexer.rs new file mode 100644 index 0000000..96263ef --- /dev/null +++ b/crates/shirabe-external-packages/src/seld/json_lint/lexer.rs @@ -0,0 +1,545 @@ +//! ref: composer/vendor/seld/jsonlint/src/Seld/JsonLint/Lexer.php + +use super::JsonParser; +use super::ParsingException; + +/// ref: array{first_line, first_column, last_line, last_column} ($yylloc) +#[derive(Debug, Clone, Default)] +pub struct YylLoc { + pub first_line: i64, + pub first_column: i64, + pub last_line: i64, + pub last_column: i64, +} + +#[derive(Debug)] +pub struct Lexer { + rules: Vec<Rule>, + input: Vec<u8>, + more: bool, + done: bool, + offset: usize, + flags: u32, + + pub match_: Vec<u8>, + pub yylineno: i64, + pub yyleng: i64, + pub yytext: Vec<u8>, + pub yylloc: YylLoc, +} + +/// Each variant replicates the corresponding `\G`-anchored PHP rule. The PCRE rules are not portable +/// to the `regex` crate (possessive quantifiers, `\G`, `\b`), so they are matched by hand. +#[derive(Debug, Clone, Copy)] +enum Rule { + BreakLine, // 0: /\G\s*\n\r?/ + Whitespace, // 1: /\G\s+/ + Number, // 2 + Str, // 3 + BraceOpen, // 4: { + BraceClose, // 5: } + BracketOpen, // 6: [ + BracketClose, // 7: ] + Comma, // 8: , + Colon, // 9: : + True, // 10 + False, // 11 + Null, // 12 + End, // 13: /\G$/ + LineComment, // 14: // + OpenComment, // 15: /* + CloseComment, // 16: */ + AnyChar, // 17: /\G./ +} + +impl Lexer { + pub const EOF: i64 = 1; + pub const T_INVALID: i64 = -1; + pub const T_SKIP_WHITESPACE: i64 = 0; + pub const T_ERROR: i64 = 2; + pub const T_BREAK_LINE: i64 = 3; + pub const T_COMMENT: i64 = 30; + pub const T_OPEN_COMMENT: i64 = 31; + pub const T_CLOSE_COMMENT: i64 = 32; + + pub fn new(flags: u32) -> Self { + let rules = vec![ + Rule::BreakLine, + Rule::Whitespace, + Rule::Number, + Rule::Str, + Rule::BraceOpen, + Rule::BraceClose, + Rule::BracketOpen, + Rule::BracketClose, + Rule::Comma, + Rule::Colon, + Rule::True, + Rule::False, + Rule::Null, + Rule::End, + Rule::LineComment, + Rule::OpenComment, + Rule::CloseComment, + Rule::AnyChar, + ]; + Self { + rules, + input: Vec::new(), + more: false, + done: false, + offset: 0, + flags, + match_: Vec::new(), + yylineno: 0, + yyleng: 0, + yytext: Vec::new(), + yylloc: YylLoc::default(), + } + } + + pub fn lex(&mut self) -> Result<i64, ParsingException> { + loop { + let symbol = self.next()?; + match symbol { + Self::T_SKIP_WHITESPACE | Self::T_BREAK_LINE => {} + Self::T_COMMENT | Self::T_OPEN_COMMENT => { + if self.flags & JsonParser::ALLOW_COMMENTS == 0 { + return Err(self.parse_error(format!( + "Lexical error on line {}. Comments are not allowed.\n{}", + self.yylineno + 1, + self.show_position() + ))); + } + let until = if symbol == Self::T_COMMENT { + Self::T_BREAK_LINE + } else { + Self::T_CLOSE_COMMENT + }; + self.skip_until(until)?; + if self.done { + // last symbol '/\G$/' before EOF + return Ok(14); + } + } + Self::T_CLOSE_COMMENT => { + return Err(self.parse_error(format!( + "Lexical error on line {}. Unexpected token.\n{}", + self.yylineno + 1, + self.show_position() + ))); + } + _ => return Ok(symbol), + } + } + } + + pub fn set_input(&mut self, input: &str) { + self.input = input.as_bytes().to_vec(); + self.more = false; + self.done = false; + self.offset = 0; + self.yylineno = 0; + self.yyleng = 0; + self.yytext = Vec::new(); + self.match_ = Vec::new(); + self.yylloc = YylLoc { + first_line: 1, + first_column: 0, + last_line: 1, + last_column: 0, + }; + } + + pub fn show_position(&self) -> String { + if self.yylineno == 0 && self.offset == 1 && self.match_ != b"{" { + let mut s = String::from_utf8_lossy(&self.match_).into_owned(); + s.push_str("...\n^"); + return s; + } + + let pre = str_replace_nl(&self.get_past_input()); + let dash_count = (pre.len() as i64 - 1).max(0) as usize; + let c = "-".repeat(dash_count); + + let upcoming = str_replace_nl(&self.get_upcoming_input()); + format!("{pre}{upcoming}\n{c}^") + } + + pub fn get_past_input(&self) -> Vec<u8> { + let past_length = self.offset as i64 - self.match_.len() as i64; + let prefix = if past_length > 20 { "..." } else { "" }; + let start = (past_length - 20).max(0) as usize; + let len = past_length.min(20).max(0) as usize; + let slice = substr_bytes(&self.input, start, len); + let mut out = prefix.as_bytes().to_vec(); + out.extend_from_slice(&slice); + out + } + + pub fn get_upcoming_input(&self) -> Vec<u8> { + let mut next = self.match_.clone(); + if next.len() < 20 { + let want = 20 - next.len(); + next.extend_from_slice(&substr_bytes(&self.input, self.offset, want)); + } + let too_long = next.len() > 20; + let mut out = substr_bytes(&next, 0, 20); + if too_long { + out.extend_from_slice(b"..."); + } + out + } + + pub fn get_full_upcoming_input(&self) -> Vec<u8> { + let mut next = self.match_.clone(); + if next.first() == Some(&b'"') && byte_count(&next, b'"') == 1 { + let len = self.input.len(); + let str_end = if len == self.offset { + len + } else { + let q = find_byte_from(&self.input, b'"', self.offset + 1).unwrap_or(len); + let q = if q == 0 { len } else { q }; + let n = find_byte_from(&self.input, b'\n', self.offset + 1).unwrap_or(len); + let n = if n == 0 { len } else { n }; + q.min(n) + }; + next.extend_from_slice(&self.input[self.offset..str_end]); + } else if next.len() < 20 { + let want = 20 - next.len(); + next.extend_from_slice(&substr_bytes(&self.input, self.offset, want)); + } + next + } + + fn parse_error(&self, str: String) -> ParsingException { + ParsingException::new(str, Default::default()) + } + + fn skip_until(&mut self, token: i64) -> Result<(), ParsingException> { + let mut symbol = self.next()?; + while symbol != token && !self.done { + symbol = self.next()?; + } + Ok(()) + } + + fn next(&mut self) -> Result<i64, ParsingException> { + if self.done { + return Ok(Self::EOF); + } + if self.offset == self.input.len() { + self.done = true; + } + + if !self.more { + self.yytext = Vec::new(); + self.match_ = Vec::new(); + } + + for i in 0..self.rules.len() { + if let Some(matched) = self.match_rule(self.rules[i]) { + let lines: Vec<&[u8]> = split_bytes(&matched, b'\n'); + // array_shift: drop first element, count remaining + let line_count = lines.len().saturating_sub(1); + self.yylineno += line_count as i64; + self.yylloc = YylLoc { + first_line: self.yylloc.last_line, + last_line: self.yylineno + 1, + first_column: self.yylloc.last_column, + last_column: if line_count > 0 { + lines[lines.len() - 1].len() as i64 + } else { + self.yylloc.last_column + matched.len() as i64 + }, + }; + self.yytext.extend_from_slice(&matched); + self.match_.extend_from_slice(&matched); + self.yyleng = self.yytext.len() as i64; + self.more = false; + self.offset += matched.len(); + return Ok(self.perform_action(i, &matched)); + } + } + + if self.offset == self.input.len() { + return Ok(Self::EOF); + } + + Err(self.parse_error(format!( + "Lexical error on line {}. Unrecognized text.\n{}", + self.yylineno + 1, + self.show_position() + ))) + } + + /// Returns the matched bytes (anchored at `self.offset`) if the rule applies, mirroring + /// `preg_match($rule, $input, $m, 0, $offset)` for the corresponding `\G`-anchored PHP pattern. + fn match_rule(&self, rule: Rule) -> Option<Vec<u8>> { + let input = &self.input; + let off = self.offset; + match rule { + Rule::BreakLine => match_break_line(input, off), + Rule::Whitespace => match_whitespace(input, off), + Rule::Number => match_number(input, off), + Rule::Str => match_string(input, off), + Rule::BraceOpen => match_char(input, off, b'{'), + Rule::BraceClose => match_char(input, off, b'}'), + Rule::BracketOpen => match_char(input, off, b'['), + Rule::BracketClose => match_char(input, off, b']'), + Rule::Comma => match_char(input, off, b','), + Rule::Colon => match_char(input, off, b':'), + Rule::True => match_keyword(input, off, b"true"), + Rule::False => match_keyword(input, off, b"false"), + Rule::Null => match_keyword(input, off, b"null"), + Rule::End => match_end(input, off), + Rule::LineComment => match_literal(input, off, b"//"), + Rule::OpenComment => match_literal(input, off, b"/*"), + Rule::CloseComment => match_literal(input, off, b"*/"), + Rule::AnyChar => match_any_char(input, off), + } + } + + fn perform_action(&mut self, rule: usize, _matched: &[u8]) -> i64 { + match rule { + 0 => Self::T_BREAK_LINE, + 1 => Self::T_SKIP_WHITESPACE, + 2 => 6, + 3 => { + // strip surrounding quotes: substr($yytext, 1, $yyleng-2) + let len = self.yyleng; + self.yytext = substr_bytes(&self.yytext, 1, (len - 2).max(0) as usize); + 4 + } + 4 => 17, + 5 => 18, + 6 => 23, + 7 => 24, + 8 => 22, + 9 => 21, + 10 => 10, + 11 => 11, + 12 => 8, + 13 => 14, + 14 => Self::T_COMMENT, + 15 => Self::T_OPEN_COMMENT, + 16 => Self::T_CLOSE_COMMENT, + 17 => Self::T_INVALID, + _ => panic!("Unsupported rule {rule}"), + } + } +} + +/// PHP `\s` (matches space, \t, \n, \r, \f, \v). +fn is_php_space(b: u8) -> bool { + matches!(b, b' ' | b'\t' | b'\n' | b'\r' | 0x0c | 0x0b) +} + +/// Rule 0: /\G\s*\n\r?/ — greedy whitespace ending at a newline, optionally followed by `\r`. +fn match_break_line(input: &[u8], off: usize) -> Option<Vec<u8>> { + // Find the run of whitespace that ends with `\n` (followed by optional `\r`). PCRE backtracks + // so the `\s*` consumes up to and including the last reachable `\n` within the leading run. + let mut i = off; + let mut last_nl: Option<usize> = None; + while i < input.len() && is_php_space(input[i]) { + if input[i] == b'\n' { + last_nl = Some(i); + } + i += 1; + } + let nl = last_nl?; + let mut end = nl + 1; + if end < input.len() && input[end] == b'\r' { + end += 1; + } + Some(input[off..end].to_vec()) +} + +/// Rule 1: /\G\s+/ +fn match_whitespace(input: &[u8], off: usize) -> Option<Vec<u8>> { + let mut i = off; + while i < input.len() && is_php_space(input[i]) { + i += 1; + } + if i == off { + None + } else { + Some(input[off..i].to_vec()) + } +} + +/// Rule 2: /\G-?([0-9]|[1-9][0-9]+)(\.[0-9]+)?([eE][+-]?[0-9]+)?\b/ +fn match_number(input: &[u8], off: usize) -> Option<Vec<u8>> { + let mut i = off; + if i < input.len() && input[i] == b'-' { + i += 1; + } + // integer part: a single digit OR [1-9][0-9]+ + let int_start = i; + if i >= input.len() || !input[i].is_ascii_digit() { + return None; + } + if input[i] == b'0' { + // single 0 only (the [1-9][0-9]+ alternative cannot start with 0) + i += 1; + } else { + // [1-9] then [0-9]* ; but grammar is `[0-9]` (single) | `[1-9][0-9]+` (2+ digits). + // Both collapse to: one or more digits starting with 1-9. + i += 1; + while i < input.len() && input[i].is_ascii_digit() { + i += 1; + } + } + let _ = int_start; + // fraction + if i < input.len() && input[i] == b'.' { + let mut j = i + 1; + let frac_start = j; + while j < input.len() && input[j].is_ascii_digit() { + j += 1; + } + if j > frac_start { + i = j; + } + } + // exponent + if i < input.len() && (input[i] == b'e' || input[i] == b'E') { + let mut j = i + 1; + if j < input.len() && (input[j] == b'+' || input[j] == b'-') { + j += 1; + } + let exp_start = j; + while j < input.len() && input[j].is_ascii_digit() { + j += 1; + } + if j > exp_start { + i = j; + } + } + // \b word boundary: previous char (input[i-1]) is a word char (digit), so the next char must be + // a non-word char (or end). + if i < input.len() && is_word_byte(input[i]) { + return None; + } + Some(input[off..i].to_vec()) +} + +fn is_word_byte(b: u8) -> bool { + b.is_ascii_alphanumeric() || b == b'_' +} + +/// Rule 3: {\G"(?>\\["bfnrt/\\]|\\u[a-fA-F0-9]{4}|[^\0-\x1f\\"]++)*+"} +fn match_string(input: &[u8], off: usize) -> Option<Vec<u8>> { + if input.get(off) != Some(&b'"') { + return None; + } + let mut i = off + 1; + loop { + let c = *input.get(i)?; + if c == b'"' { + return Some(input[off..=i].to_vec()); + } + if c == b'\\' { + let n = *input.get(i + 1)?; + match n { + b'"' | b'b' | b'f' | b'n' | b'r' | b't' | b'/' | b'\\' => { + i += 2; + } + b'u' => { + // require 4 hex digits + for k in 0..4 { + let h = *input.get(i + 2 + k)?; + if !h.is_ascii_hexdigit() { + return None; + } + } + i += 6; + } + _ => return None, + } + } else if c <= 0x1f { + // [^\0-\x1f\\"] excludes control chars + return None; + } else { + i += 1; + } + } +} + +/// Rules 4-9: single literal char. +fn match_char(input: &[u8], off: usize, ch: u8) -> Option<Vec<u8>> { + if input.get(off) == Some(&ch) { + Some(vec![ch]) + } else { + None + } +} + +/// Rules 10-12: keyword followed by a `\b` boundary. +fn match_keyword(input: &[u8], off: usize, kw: &[u8]) -> Option<Vec<u8>> { + if input.len() < off + kw.len() || &input[off..off + kw.len()] != kw { + return None; + } + let after = off + kw.len(); + if after < input.len() && is_word_byte(input[after]) { + return None; + } + Some(kw.to_vec()) +} + +/// Rule 13: /\G$/ — matches the empty string at end of input (or before a trailing final newline, +/// per PCRE `$` without the `m` modifier). +fn match_end(input: &[u8], off: usize) -> Option<Vec<u8>> { + if off == input.len() { + return Some(Vec::new()); + } + if off == input.len() - 1 && input[off] == b'\n' { + return Some(Vec::new()); + } + None +} + +/// Rules 14-16: multi-char literal. +fn match_literal(input: &[u8], off: usize, lit: &[u8]) -> Option<Vec<u8>> { + if input.len() >= off + lit.len() && &input[off..off + lit.len()] == lit { + Some(lit.to_vec()) + } else { + None + } +} + +/// Rule 17: /\G./ — any single character except newline. +fn match_any_char(input: &[u8], off: usize) -> Option<Vec<u8>> { + match input.get(off) { + Some(&b'\n') | None => None, + Some(&c) => Some(vec![c]), + } +} + +fn substr_bytes(input: &[u8], start: usize, len: usize) -> Vec<u8> { + if start >= input.len() { + return Vec::new(); + } + let end = (start + len).min(input.len()); + input[start..end].to_vec() +} + +fn str_replace_nl(input: &[u8]) -> String { + let filtered: Vec<u8> = input.iter().copied().filter(|&b| b != b'\n').collect(); + String::from_utf8_lossy(&filtered).into_owned() +} + +fn split_bytes(input: &[u8], sep: u8) -> Vec<&[u8]> { + input.split(|&b| b == sep).collect() +} + +fn byte_count(input: &[u8], b: u8) -> usize { + input.iter().filter(|&&x| x == b).count() +} + +fn find_byte_from(input: &[u8], b: u8, from: usize) -> Option<usize> { + if from > input.len() { + return None; + } + input[from..].iter().position(|&x| x == b).map(|p| p + from) +} diff --git a/crates/shirabe-external-packages/src/seld/json_lint/mod.rs b/crates/shirabe-external-packages/src/seld/json_lint/mod.rs index 1058268..8027cb2 100644 --- a/crates/shirabe-external-packages/src/seld/json_lint/mod.rs +++ b/crates/shirabe-external-packages/src/seld/json_lint/mod.rs @@ -1,7 +1,9 @@ pub mod duplicate_key_exception; pub mod json_parser; +pub mod lexer; pub mod parsing_exception; pub use duplicate_key_exception::*; pub use json_parser::*; +pub use lexer::*; pub use parsing_exception::*; diff --git a/crates/shirabe-external-packages/src/symfony/console/helper/table.rs b/crates/shirabe-external-packages/src/symfony/console/helper/table.rs index 1167d63..bc426f6 100644 --- a/crates/shirabe-external-packages/src/symfony/console/helper/table.rs +++ b/crates/shirabe-external-packages/src/symfony/console/helper/table.rs @@ -14,6 +14,7 @@ use crate::symfony::console::helper::table_style::TableStyle; use crate::symfony::console::output::console_section_output::ConsoleSectionOutput; use crate::symfony::console::output::output_interface::OutputInterface; use indexmap::IndexMap; +use shirabe_php_shim::AsAny; use shirabe_php_shim::PhpMixed; use std::cell::RefCell; use std::rc::Rc; @@ -1248,7 +1249,9 @@ impl Table { fn formatter_is_wrappable(_output: &Rc<RefCell<dyn OutputInterface>>) -> bool { // PHP: $this->output->getFormatter() instanceof WrappableOutputFormatterInterface - // TODO(phase-b): trait-to-trait instanceof check requires concrete formatter knowledge. + // TODO(phase-c/d): instanceof on `dyn OutputFormatterInterface` needs an AsAny supertrait + // on OutputFormatterInterface to downcast to the concrete wrappable formatter; adding it + // would touch output_formatter_interface.rs, which is out of scope for this file. let _ = std::any::type_name::<dyn WrappableOutputFormatterInterface>(); todo!() } @@ -1260,9 +1263,13 @@ impl Table { Helper::remove_decoration(&mut *formatter, string) } - fn output_is_console_section(_output: &Rc<RefCell<dyn OutputInterface>>) -> bool { + fn output_is_console_section(output: &Rc<RefCell<dyn OutputInterface>>) -> bool { // PHP: $this->output instanceof ConsoleSectionOutput - todo!() + let borrowed = output.borrow(); + (*borrowed) + .as_any() + .downcast_ref::<ConsoleSectionOutput>() + .is_some() } fn is_divider(_row: &PhpMixed, _divider: &TableSeparator) -> bool { diff --git a/crates/shirabe-external-packages/src/symfony/process/executable_finder.rs b/crates/shirabe-external-packages/src/symfony/process/executable_finder.rs index cfd1bef..a151283 100644 --- a/crates/shirabe-external-packages/src/symfony/process/executable_finder.rs +++ b/crates/shirabe-external-packages/src/symfony/process/executable_finder.rs @@ -1,7 +1,18 @@ //! ref: composer/vendor/symfony/process/ExecutableFinder.php +use shirabe_php_shim::{self as php, PhpMixed}; + +const CMD_BUILTINS: &[&str] = &[ + "assoc", "break", "call", "cd", "chdir", "cls", "color", "copy", "date", "del", "dir", "echo", + "endlocal", "erase", "exit", "for", "ftype", "goto", "help", "if", "label", "md", "mkdir", + "mklink", "move", "path", "pause", "popd", "prompt", "pushd", "rd", "rem", "ren", "rename", + "rmdir", "set", "setlocal", "shift", "start", "time", "title", "type", "ver", "vol", +]; + #[derive(Debug)] -pub struct ExecutableFinder; +pub struct ExecutableFinder { + suffixes: Vec<String>, +} impl Default for ExecutableFinder { fn default() -> Self { @@ -11,14 +22,102 @@ impl Default for ExecutableFinder { impl ExecutableFinder { pub fn new() -> Self { - todo!() + Self { suffixes: vec![] } } - pub fn add_suffix(&mut self, _suffix: &str) { - todo!() + /// Replaces default suffixes of executable. + pub fn set_suffixes(&mut self, suffixes: Vec<String>) { + self.suffixes = suffixes; } - pub fn find(&self, _name: &str, _default: Option<&str>, _dirs: &[String]) -> Option<String> { - todo!() + /// Adds new possible suffix to check for executable. + pub fn add_suffix(&mut self, suffix: &str) { + self.suffixes.push(suffix.to_string()); + } + + pub fn find(&self, name: &str, default: Option<&str>, extra_dirs: &[String]) -> Option<String> { + // windows built-in commands that are present in cmd.exe should not be resolved using PATH as they do not exist as exes + if php::DIRECTORY_SEPARATOR == "\\" + && CMD_BUILTINS.contains(&php::strtolower(name).as_str()) + { + return Some(name.to_string()); + } + + let path = php::getenv("PATH") + .or_else(|| php::getenv("Path")) + .map(|v| v.to_string_lossy().into_owned()) + .unwrap_or_default(); + let mut dirs = php::explode(php::PATH_SEPARATOR, &path); + dirs.extend_from_slice(extra_dirs); + + let mut suffixes: Vec<String> = vec![]; + if php::DIRECTORY_SEPARATOR == "\\" { + let path_ext = php::getenv("PATHEXT").map(|v| v.to_string_lossy().into_owned()); + suffixes = self.suffixes.clone(); + let exts = match path_ext { + Some(ref ext) if !ext.is_empty() => php::explode(php::PATH_SEPARATOR, ext), + _ => vec![ + ".exe".to_string(), + ".bat".to_string(), + ".cmd".to_string(), + ".com".to_string(), + ], + }; + suffixes.extend(exts); + } + suffixes = if !php::pathinfo(PhpMixed::String(name.to_string()), php::PATHINFO_EXTENSION) + .as_string() + .unwrap_or("") + .is_empty() + { + let mut s = vec![String::new()]; + s.extend(suffixes); + s + } else { + suffixes.push(String::new()); + suffixes + }; + for suffix in &suffixes { + for dir in &dirs { + let dir = if dir.is_empty() { "." } else { dir.as_str() }; + let file = format!("{dir}{}{name}{suffix}", php::DIRECTORY_SEPARATOR); + if php::is_file(&file) + && (php::DIRECTORY_SEPARATOR == "\\" || php::is_executable(&file)) + { + return Some(file); + } + + if !php::is_dir(dir) + && php::basename(dir) == format!("{name}{suffix}") + && php::is_executable(dir) + { + return Some(dir.to_string()); + } + } + } + + if php::DIRECTORY_SEPARATOR == "\\" + || name.len() != php::strcspn(name, &format!("/{}", php::DIRECTORY_SEPARATOR)) + { + return default.map(ToString::to_string); + } + + let exec_result = php::exec( + &format!("command -v -- {}", php::escapeshellarg(name)), + None, + None, + ) + .unwrap_or_default(); + + let executable_path = php::substr( + &exec_result, + 0, + php::strpos(&exec_result, php::PHP_EOL).map(|i| i as i64), + ); + if !executable_path.is_empty() && php::is_executable(&executable_path) { + return Some(executable_path); + } + + default.map(ToString::to_string) } } diff --git a/crates/shirabe-external-packages/src/symfony/process/php_executable_finder.rs b/crates/shirabe-external-packages/src/symfony/process/php_executable_finder.rs index 65eae30..9d04c5e 100644 --- a/crates/shirabe-external-packages/src/symfony/process/php_executable_finder.rs +++ b/crates/shirabe-external-packages/src/symfony/process/php_executable_finder.rs @@ -1,7 +1,12 @@ //! ref: composer/vendor/symfony/process/PhpExecutableFinder.php +use super::executable_finder::ExecutableFinder; +use shirabe_php_shim::{self as php}; + #[derive(Debug)] -pub struct PhpExecutableFinder; +pub struct PhpExecutableFinder { + executable_finder: ExecutableFinder, +} impl Default for PhpExecutableFinder { fn default() -> Self { @@ -11,14 +16,52 @@ impl Default for PhpExecutableFinder { impl PhpExecutableFinder { pub fn new() -> Self { - todo!() + Self { + executable_finder: ExecutableFinder::new(), + } } - pub fn find(&self, _include_args: bool) -> Option<String> { + /// Finds The PHP executable. + pub fn find(&self, include_args: bool) -> Option<String> { + if let Some(php) = php::getenv("PHP_BINARY").filter(|v| !v.is_empty()) { + let mut php = php.to_string_lossy().into_owned(); + if !php::is_executable(&php) { + match self.executable_finder.find(&php, None, &[]) { + Some(found) => php = found, + None => return None, + } + } + + if php::is_dir(&php) { + return None; + } + + return Some(php); + } + + let args = self.find_arguments(); + let _args = if include_args && !args.is_empty() { + format!(" {}", args.join(" ")) + } else { + String::new() + }; + + // PHP_BINARY return the current sapi executable + // + // Everything from here on depends on runtime constants describing the *running* PHP + // interpreter (\PHP_BINARY truthiness, \PHP_SAPI, \PHP_BINDIR). The shim does not model a + // current PHP runtime, so the remaining fallbacks (the \PHP_SAPI sapi check, \PHP_PATH, + // \PHP_PEAR_PHP_BIN, \PHP_BINDIR probing and the final php lookup seeded with \PHP_BINDIR) + // cannot be ported faithfully here. + // TODO(php-runtime): port once the shim exposes \PHP_SAPI and \PHP_BINDIR. todo!() } + /// Finds the PHP executable arguments. pub fn find_arguments(&self) -> Vec<String> { + let _arguments: Vec<String> = vec![]; + // TODO(php-runtime): \PHP_SAPI is the SAPI name of the running PHP interpreter; the shim + // does not model a current PHP runtime, so the 'phpdbg' check cannot be ported faithfully. todo!() } } diff --git a/crates/shirabe-external-packages/src/symfony/string/byte_string.rs b/crates/shirabe-external-packages/src/symfony/string/byte_string.rs index ab17c1e..cd8eae8 100644 --- a/crates/shirabe-external-packages/src/symfony/string/byte_string.rs +++ b/crates/shirabe-external-packages/src/symfony/string/byte_string.rs @@ -8,11 +8,25 @@ pub struct ByteString { } impl ByteString { - pub fn new(_string: &str) -> Self { - todo!() + pub fn new(string: &str) -> Self { + Self { + string: string.to_string(), + } } - pub fn to_code_point_string(&self, _encoding: &str) -> CodePointString { + pub fn to_code_point_string(&self, from_encoding: &str) -> CodePointString { + // The source `string` is always valid UTF-8 (Rust `String`/`&str` guarantee). PHP takes the + // early-return branch whenever `preg_match('//u', ...)` holds for a UTF-8/null encoding, so + // the result mirrors the input bytes verbatim. The `mb_detect_encoding`/`iconv` conversion + // path only applies to genuinely non-UTF-8 byte strings, which cannot occur here. + if matches!(from_encoding, "" | "utf8" | "utf-8" | "UTF8" | "UTF-8") { + return CodePointString { + string: self.string.clone(), + }; + } + + // TODO(phase-d): non-UTF-8 source encodings would require mb_convert_encoding/iconv-style + // decoding, which is unreachable for the UTF-8-only inputs Shirabe currently produces. todo!() } } diff --git a/crates/shirabe-external-packages/src/symfony/string/code_point_string.rs b/crates/shirabe-external-packages/src/symfony/string/code_point_string.rs index b76f157..ce67428 100644 --- a/crates/shirabe-external-packages/src/symfony/string/code_point_string.rs +++ b/crates/shirabe-external-packages/src/symfony/string/code_point_string.rs @@ -6,11 +6,166 @@ pub struct CodePointString { } impl CodePointString { - pub fn wordwrap(&self, _width: i64, _break: &str, _cut: bool) -> Self { - todo!() + /// Port of `AbstractString::wordwrap()`, specialised to the non-`ignoreCase` code-point case. + pub fn wordwrap(&self, width: i64, r#break: &str, cut: bool) -> Self { + // `split($break)` with no flags reduces to `explode($break, $string)` here, then `chunk()` + // yields one entry per code point. `ignoreCase` is always false for freshly built instances. + let lines: Vec<&str> = if !r#break.is_empty() { + self.string.split(r#break).collect() + } else { + vec![&self.string] + }; + + let mut chars: Vec<String> = Vec::new(); + let mut mask = String::new(); + + if lines.len() == 1 && lines[0].is_empty() { + return Self { + string: String::new(), + }; + } + + for (i, line) in lines.iter().enumerate() { + if i != 0 { + chars.push(r#break.to_string()); + mask.push('#'); + } + + for ch in line.chars() { + let s = ch.to_string(); + mask.push(if s == " " { ' ' } else { '?' }); + chars.push(s); + } + } + + let mut string = String::new(); + let mut j: usize = 0; + // PHP seeds both `$b` and `$i` at -1; mirror with signed indices. + let mut i: i64 = -1; + let mask = php_wordwrap(&mask, width, "#", cut); + let mask_bytes = mask.as_bytes(); + + let mut b: i64 = -1; + loop { + // strpos($mask, '#', $b + 1) + let from = (b + 1) as usize; + let Some(rel) = mask_bytes[from..].iter().position(|&c| c == b'#') else { + break; + }; + b = (from + rel) as i64; + + i += 1; + while i < b { + string.push_str(&chars[j]); + j += 1; + i += 1; + } + + if chars[j] == r#break || chars[j] == " " { + j += 1; + } + + string.push_str(r#break); + } + + for c in &chars[j..] { + string.push_str(c); + } + + Self { string } } - pub fn to_byte_string(&self, _encoding: &str) -> String { + pub fn to_byte_string(&self, to_encoding: &str) -> String { + // The source is always valid UTF-8, so PHP's `toByteString` returns the string verbatim + // whenever the target is null/UTF-8 (the only encodings reached here). The + // mb_convert_encoding/iconv path applies only to non-UTF-8 targets, which do not occur. + if matches!(to_encoding, "" | "utf8" | "utf-8" | "UTF8" | "UTF-8") { + return self.string.clone(); + } + + // TODO(phase-d): converting to a non-UTF-8 target encoding needs mb_convert_encoding/iconv, + // unreachable for Shirabe's UTF-8-only output. todo!() } } + +/// Port of PHP's built-in `wordwrap()` (`PHP_FUNCTION(wordwrap)` in ext/standard/string.c). +/// Byte-based, matching PHP's single-byte/multi-byte break and cut handling. +fn php_wordwrap(text: &str, linelength: i64, breakchar: &str, docut: bool) -> String { + let text = text.as_bytes(); + let breakchar = breakchar.as_bytes(); + let textlen = text.len() as i64; + let breaklen = breakchar.len() as i64; + + if textlen == 0 { + return String::new(); + } + + let mut laststart: i64 = 0; + let mut lastspace: i64 = 0; + + // Special case for a single-character break that needs no extra storage. + if breaklen == 1 && !docut { + let mut out = text.to_vec(); + let mut current = 0i64; + while current < textlen { + let c = out[current as usize]; + if c == breakchar[0] { + laststart = current + 1; + lastspace = current + 1; + } else if c == b' ' { + if current - laststart >= linelength { + out[current as usize] = breakchar[0]; + laststart = current + 1; + } + lastspace = current; + } else if current - laststart >= linelength && laststart != lastspace { + out[lastspace as usize] = breakchar[0]; + laststart = lastspace + 1; + } + current += 1; + } + return String::from_utf8_lossy(&out).into_owned(); + } + + // Multiple character line break or forced cut. + let mut out: Vec<u8> = Vec::new(); + let mut current = 0i64; + while current < textlen { + // When we hit an existing break, copy to the new buffer and fix up laststart/lastspace. + if text[current as usize] == breakchar[0] + && current + breaklen < textlen + && &text[current as usize..(current + breaklen) as usize] == breakchar + { + out.extend_from_slice(&text[laststart as usize..(current + breaklen) as usize]); + current += breaklen - 1; + laststart = current + 1; + lastspace = current + 1; + } else if text[current as usize] == b' ' { + if current - laststart >= linelength { + out.extend_from_slice(&text[laststart as usize..current as usize]); + out.extend_from_slice(breakchar); + laststart = current + 1; + } + lastspace = current; + } else if current - laststart >= linelength && docut && laststart >= lastspace { + out.extend_from_slice(&text[laststart as usize..current as usize]); + out.extend_from_slice(breakchar); + laststart = current; + lastspace = current; + } else if current - laststart >= linelength && laststart < lastspace { + out.extend_from_slice(&text[laststart as usize..lastspace as usize]); + out.extend_from_slice(breakchar); + laststart = lastspace + 1; + lastspace = lastspace + 1; + } + current += 1; + } + + // Copy over any stragglers. + if laststart != current { + out.extend_from_slice(&text[laststart as usize..current as usize]); + } + + String::from_utf8_lossy(&out).into_owned() +} diff --git a/crates/shirabe-external-packages/src/symfony/string/mod.rs b/crates/shirabe-external-packages/src/symfony/string/mod.rs index 411a3f3..139d726 100644 --- a/crates/shirabe-external-packages/src/symfony/string/mod.rs +++ b/crates/shirabe-external-packages/src/symfony/string/mod.rs @@ -6,11 +6,12 @@ pub use byte_string::*; pub use code_point_string::*; pub use unicode_string::*; -/// Mirror of Symfony's `u()` / `b()` helper functions. -pub fn b(_string: &str) -> ByteString { - todo!() +/// Mirror of Symfony's `b()` helper function. +pub fn b(string: &str) -> ByteString { + ByteString::new(string) } -pub fn s(_string: &str) -> UnicodeString { - todo!() +/// Mirror of Symfony's `u()` helper function. +pub fn s(string: &str) -> UnicodeString { + UnicodeString::new(string) } diff --git a/crates/shirabe-external-packages/src/symfony/string/unicode_string.rs b/crates/shirabe-external-packages/src/symfony/string/unicode_string.rs index 8917f20..e415846 100644 --- a/crates/shirabe-external-packages/src/symfony/string/unicode_string.rs +++ b/crates/shirabe-external-packages/src/symfony/string/unicode_string.rs @@ -34,11 +34,23 @@ impl UnicodeString { width } + // TODO(phase-d): the faithful `length()` uses `grapheme_strlen` (extended grapheme clusters), + // which needs Unicode segmentation tables with no Rust std equivalent and no permitted crate. + // Approximated with the code-point count, exact only when no combining/multi-code-point + // clusters are present (e.g. ASCII). pub fn length(&self) -> i64 { - todo!() + shirabe_php_shim::mb_strlen(&self.string, "UTF-8") } - pub fn slice(&self, _start: i64, _length: Option<i64>) -> Self { - todo!() + // TODO(phase-d): the faithful `slice()` uses `grapheme_substr` (grapheme-cluster offsets), which + // needs Unicode segmentation tables with no std equivalent and no permitted crate. Approximated + // with code-point offsets via `mb_substr`, exact only without combining/multi-code-point clusters. + pub fn slice(&self, start: i64, length: Option<i64>) -> Self { + Self::new(&shirabe_php_shim::mb_substr( + &self.string, + start, + length, + Some("UTF-8"), + )) } } diff --git a/crates/shirabe-php-shim/src/datetime.rs b/crates/shirabe-php-shim/src/datetime.rs index 9810966..bddf5da 100644 --- a/crates/shirabe-php-shim/src/datetime.rs +++ b/crates/shirabe-php-shim/src/datetime.rs @@ -1,6 +1,10 @@ static DEFAULT_TIMEZONE: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None); pub fn date_create<Tz: chrono::TimeZone>(s: &str) -> chrono::ParseResult<chrono::DateTime<Tz>> { + // TODO(phase-d): PHP `date_create` accepts the full strtotime() grammar (RFC2822, ISO8601, + // VCS-specific and relative formats), which requires a dedicated date parser not available + // here. The generic `Tz` also has no constructor from a parsed `FixedOffset`/`Utc` value. + let _ = s; todo!() } @@ -22,6 +26,8 @@ pub fn date_format_to_strftime(format: &str) -> &'static str { } pub fn strtotime(_time: &str) -> Option<i64> { + // TODO(phase-d): requires the full strtotime() grammar (absolute, relative and compound + // expressions); a partial parser would silently mis-handle unsupported inputs. todo!() } @@ -53,6 +59,17 @@ pub fn date_default_timezone_set(tz: &str) -> bool { true } -pub fn date(_format: &str, _timestamp: Option<i64>) -> String { - todo!() +pub fn date(format: &str, timestamp: Option<i64>) -> String { + let timestamp = timestamp.unwrap_or_else(time); + // PHP `date()` renders in the default timezone. Without a timezone database only "UTC" can be + // resolved; any named zone is rejected loudly rather than silently rendered in the wrong zone. + let tz = date_default_timezone_get(); + if tz != "UTC" { + panic!( + "date() with non-UTC default timezone {tz:?} is not supported (no timezone database)" + ); + } + let dt = chrono::DateTime::<chrono::Utc>::from_timestamp(timestamp, 0) + .expect("date() timestamp out of range"); + dt.format(date_format_to_strftime(format)).to_string() } diff --git a/crates/shirabe-php-shim/src/output.rs b/crates/shirabe-php-shim/src/output.rs index 17f7ab9..698f3bf 100644 --- a/crates/shirabe-php-shim/src/output.rs +++ b/crates/shirabe-php-shim/src/output.rs @@ -1,3 +1,6 @@ +// PHP output buffering captures everything the interpreter would echo to stdout. The shim has no +// general echo-to-buffer routing, and its only producer in Composer (`phpinfo`) depends on PHP +// runtime configuration that is itself unmodeled, so a buffer here would silently capture nothing. pub fn ob_start() -> bool { todo!() } diff --git a/crates/shirabe-php-shim/src/stream.rs b/crates/shirabe-php-shim/src/stream.rs index e5e5200..f5582a7 100644 --- a/crates/shirabe-php-shim/src/stream.rs +++ b/crates/shirabe-php-shim/src/stream.rs @@ -13,6 +13,8 @@ pub fn stream_get_contents(stream: &PhpResource) -> Option<String> { } pub fn stream_resolve_include_path(filename: &str) -> Option<String> { + // TODO(phase-d): resolution searches the `include_path` ini setting, which the shim does not + // model; checking only the current directory would silently miss configured include paths. let _ = filename; todo!() } @@ -60,19 +62,49 @@ fn stream_read_remaining(stream: &PhpResource, max_length: Option<i64>) -> Optio } } +// A stream context is modeled as an object holding the two arrays PHP keeps for it: the per-wrapper +// `options` and the `params`. This is a self-contained value, so it round-trips through the +// accessors below without any registry. pub fn stream_context_create( - _options: &IndexMap<String, PhpMixed>, - _params: Option<&IndexMap<String, PhpMixed>>, + options: &IndexMap<String, PhpMixed>, + params: Option<&IndexMap<String, PhpMixed>>, ) -> PhpMixed { - todo!() + let mut context = IndexMap::new(); + context.insert("options".to_string(), PhpMixed::Array(options.clone())); + context.insert( + "params".to_string(), + PhpMixed::Array(params.cloned().unwrap_or_default()), + ); + PhpMixed::Object(context) } -pub fn stream_context_get_options(_stream_or_context: &PhpMixed) -> IndexMap<String, PhpMixed> { - todo!() +pub fn stream_context_get_options(stream_or_context: &PhpMixed) -> IndexMap<String, PhpMixed> { + match stream_or_context { + PhpMixed::Object(context) | PhpMixed::Array(context) => context + .get("options") + .and_then(PhpMixed::as_array) + .cloned() + .unwrap_or_default(), + _ => IndexMap::new(), + } } -pub fn stream_context_get_params(_stream_or_context: &PhpMixed) -> IndexMap<String, PhpMixed> { - todo!() +pub fn stream_context_get_params(stream_or_context: &PhpMixed) -> IndexMap<String, PhpMixed> { + let (options, params) = match stream_or_context { + PhpMixed::Object(context) | PhpMixed::Array(context) => ( + context.get("options").cloned().unwrap_or_default(), + context + .get("params") + .and_then(PhpMixed::as_array) + .cloned() + .unwrap_or_default(), + ), + _ => (PhpMixed::default(), IndexMap::new()), + }; + // PHP exposes the wrapper options under an "options" key alongside the params. + let mut result = params; + result.insert("options".to_string(), options); + result } pub fn stream_isatty(stream: PhpResource) -> bool { @@ -80,6 +112,8 @@ pub fn stream_isatty(stream: PhpResource) -> bool { } pub fn stream_get_wrappers() -> Vec<String> { + // TODO(phase-d): the registered wrapper set depends on compiled-in extensions and runtime + // `stream_wrapper_register` calls; neither is modeled, so a fixed list would be inaccurate. todo!() } diff --git a/crates/shirabe-php-shim/src/zip.rs b/crates/shirabe-php-shim/src/zip.rs index 08662e6..6b4d87b 100644 --- a/crates/shirabe-php-shim/src/zip.rs +++ b/crates/shirabe-php-shim/src/zip.rs @@ -1,9 +1,30 @@ use crate::PhpMixed; +use crate::{StreamBacking, StreamState}; use indexmap::IndexMap; +use std::cell::RefCell; +use zip::write::SimpleFileOptions; + +/// Internal backing of an opened `ZipArchive`. PHP's `ZipArchive` multiplexes +/// reading an existing archive and building a new one through the same handle; +/// the `zip` crate splits these into `ZipArchive` (read) and `ZipWriter` (write), +/// so the open mode determines which variant is live. +#[derive(Debug, Default)] +enum ZipState { + #[default] + Closed, + Reader(zip::ZipArchive<std::fs::File>), + Writer { + writer: zip::ZipWriter<std::fs::File>, + /// The destination path, retained so `close` can confirm the file exists. + path: String, + status: String, + }, +} #[derive(Debug)] pub struct ZipArchive { pub num_files: i64, + state: RefCell<ZipState>, } impl Default for ZipArchive { @@ -14,63 +35,193 @@ impl Default for ZipArchive { impl ZipArchive { pub fn new() -> Self { - todo!() + Self { + num_files: 0, + state: RefCell::new(ZipState::Closed), + } } - pub fn open(&mut self, _filename: &str, _flags: i64) -> Result<(), i64> { - todo!() + pub fn open(&mut self, filename: &str, flags: i64) -> Result<(), i64> { + if flags & Self::CREATE != 0 { + let file = match std::fs::File::create(filename) { + Ok(f) => f, + Err(_) => return Err(Self::ER_OPEN), + }; + *self.state.borrow_mut() = ZipState::Writer { + writer: zip::ZipWriter::new(file), + path: filename.to_string(), + status: String::new(), + }; + self.num_files = 0; + Ok(()) + } else { + let file = match std::fs::File::open(filename) { + Ok(f) => f, + Err(_) => return Err(Self::ER_NOENT), + }; + let archive = match zip::ZipArchive::new(file) { + Ok(a) => a, + Err(_) => return Err(Self::ER_NOZIP), + }; + self.num_files = archive.len() as i64; + *self.state.borrow_mut() = ZipState::Reader(archive); + Ok(()) + } } pub fn close(&self) -> bool { - todo!() + let state = std::mem::take(&mut *self.state.borrow_mut()); + match state { + ZipState::Closed => false, + ZipState::Reader(_) => true, + ZipState::Writer { writer, .. } => writer.finish().is_ok(), + } } pub fn count(&self) -> i64 { - todo!() + self.num_files } - pub fn stat_index(&self, _index: i64) -> Option<IndexMap<String, PhpMixed>> { - todo!() + pub fn stat_index(&self, index: i64) -> Option<IndexMap<String, PhpMixed>> { + let mut state = self.state.borrow_mut(); + let ZipState::Reader(archive) = &mut *state else { + return None; + }; + let file = archive.by_index(index as usize).ok()?; + let mut stat = IndexMap::new(); + stat.insert( + "name".to_string(), + PhpMixed::String(file.name().to_string()), + ); + stat.insert("index".to_string(), PhpMixed::Int(index)); + stat.insert("crc".to_string(), PhpMixed::Int(file.crc32() as i64)); + stat.insert("size".to_string(), PhpMixed::Int(file.size() as i64)); + // PHP exposes the last-modified time as a Unix timestamp. The `zip` crate + // only surfaces a 2-second-precision MS-DOS datetime; no consumer reads + // this field, so it is reported as 0 rather than reconstructing it. + stat.insert("mtime".to_string(), PhpMixed::Int(0)); + stat.insert( + "comp_size".to_string(), + PhpMixed::Int(file.compressed_size() as i64), + ); + let comp_method = match file.compression() { + zip::CompressionMethod::Stored => 0, + zip::CompressionMethod::Deflated => 8, + _ => -1, + }; + stat.insert("comp_method".to_string(), PhpMixed::Int(comp_method)); + Some(stat) } - pub fn extract_to(&self, _path: &str) -> bool { - todo!() + pub fn extract_to(&self, path: &str) -> bool { + let mut state = self.state.borrow_mut(); + let ZipState::Reader(archive) = &mut *state else { + return false; + }; + archive.extract(path).is_ok() } - pub fn locate_name(&self, _name: &str) -> Option<i64> { - todo!() + pub fn locate_name(&self, name: &str) -> Option<i64> { + let state = self.state.borrow(); + let ZipState::Reader(archive) = &*state else { + return None; + }; + archive.index_for_name(name).map(|i| i as i64) } - pub fn get_from_index(&self, _index: i64) -> Option<String> { - todo!() + pub fn get_from_index(&self, index: i64) -> Option<String> { + let mut state = self.state.borrow_mut(); + let ZipState::Reader(archive) = &mut *state else { + return None; + }; + let mut file = archive.by_index(index as usize).ok()?; + let mut buf = Vec::new(); + std::io::Read::read_to_end(&mut file, &mut buf).ok()?; + Some(String::from_utf8_lossy(&buf).into_owned()) } - pub fn get_name_index(&self, _index: i64) -> String { - todo!() + pub fn get_name_index(&self, index: i64) -> String { + let mut state = self.state.borrow_mut(); + let ZipState::Reader(archive) = &mut *state else { + return String::new(); + }; + match archive.by_index(index as usize) { + Ok(file) => file.name().to_string(), + Err(_) => String::new(), + } } - pub fn get_from_name(&self, _name: &str) -> Option<String> { - todo!() + pub fn get_from_name(&self, name: &str) -> Option<String> { + let mut state = self.state.borrow_mut(); + let ZipState::Reader(archive) = &mut *state else { + return None; + }; + let mut file = archive.by_name(name).ok()?; + let mut buf = Vec::new(); + std::io::Read::read_to_end(&mut file, &mut buf).ok()?; + Some(String::from_utf8_lossy(&buf).into_owned()) } - pub fn get_stream(&self, _name: &str) -> Option<crate::PhpResource> { - todo!() + pub fn get_stream(&self, name: &str) -> Option<crate::PhpResource> { + let mut state = self.state.borrow_mut(); + let ZipState::Reader(archive) = &mut *state else { + return None; + }; + let mut file = archive.by_name(name).ok()?; + let mut buf = Vec::new(); + std::io::Read::read_to_end(&mut file, &mut buf).ok()?; + Some(StreamState::new( + StreamBacking::Memory(std::io::Cursor::new(buf)), + true, + false, + "r".to_string(), + format!("zip://{}", name), + )) } - pub fn add_empty_dir(&self, _local_name: &str) -> bool { - todo!() + pub fn add_empty_dir(&self, local_name: &str) -> bool { + let mut state = self.state.borrow_mut(); + let ZipState::Writer { writer, .. } = &mut *state else { + return false; + }; + writer + .add_directory(local_name, SimpleFileOptions::default()) + .is_ok() } - pub fn add_file(&self, _filepath: &str, _local_name: &str) -> bool { - todo!() + pub fn add_file(&self, filepath: &str, local_name: &str) -> bool { + let contents = match std::fs::read(filepath) { + Ok(c) => c, + Err(_) => return false, + }; + let mut state = self.state.borrow_mut(); + let ZipState::Writer { writer, .. } = &mut *state else { + return false; + }; + let options = + SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); + if writer.start_file(local_name, options).is_err() { + return false; + } + std::io::Write::write_all(writer, &contents).is_ok() } pub fn set_external_attributes_name(&self, _name: &str, _opsys: i64, _attr: i64) -> bool { + // TODO(phase-d): PHP's setExternalAttributesName mutates an already-added + // entry's external attributes (e.g. Unix permissions) after addFile. The + // `zip` crate fixes external attributes at start_file time via FileOptions + // and exposes no API to amend a written entry, so this cannot be faithfully + // reproduced without re-architecting add_file. Left unimplemented rather + // than silently dropping the permission bits. todo!() } pub fn get_status_string(&self) -> String { - todo!() + let state = self.state.borrow(); + match &*state { + ZipState::Writer { status, .. } => status.clone(), + _ => String::new(), + } } } diff --git a/crates/shirabe-spdx-licenses/Cargo.toml b/crates/shirabe-spdx-licenses/Cargo.toml index a1bd9bb..014673e 100644 --- a/crates/shirabe-spdx-licenses/Cargo.toml +++ b/crates/shirabe-spdx-licenses/Cargo.toml @@ -5,6 +5,8 @@ edition.workspace = true [dependencies] shirabe-php-shim.workspace = true +indexmap.workspace = true +serde_json.workspace = true [lints] workspace = true diff --git a/crates/shirabe-spdx-licenses/src/spdx_licenses.rs b/crates/shirabe-spdx-licenses/src/spdx_licenses.rs index b955ccd..d47b727 100644 --- a/crates/shirabe-spdx-licenses/src/spdx_licenses.rs +++ b/crates/shirabe-spdx-licenses/src/spdx_licenses.rs @@ -1,9 +1,26 @@ //! ref: composer/vendor/composer/spdx-licenses/src/SpdxLicenses.php +use indexmap::IndexMap; use shirabe_php_shim::PhpMixed; +pub const LICENSES_FILE: &str = "spdx-licenses.json"; +pub const EXCEPTIONS_FILE: &str = "spdx-exceptions.json"; + +// PHP reads the resource files from `dirname(__DIR__) . '/res'` at runtime via +// file_get_contents. Composer's res directory ships with the vendored package; embed it at compile +// time so the data is available without filesystem access. +const LICENSES_JSON: &str = + include_str!("../../../composer/vendor/composer/spdx-licenses/res/spdx-licenses.json"); +const EXCEPTIONS_JSON: &str = + include_str!("../../../composer/vendor/composer/spdx-licenses/res/spdx-exceptions.json"); + #[derive(Debug)] -pub struct SpdxLicenses; +pub struct SpdxLicenses { + // [ lowercased license identifier => (identifier, full name, osi certified, deprecated) ] + licenses: IndexMap<String, (String, String, bool, bool)>, + // [ lowercased exception identifier => (exception identifier, full name) ] + exceptions: IndexMap<String, (String, String)>, +} impl Default for SpdxLicenses { fn default() -> Self { @@ -13,18 +30,135 @@ impl Default for SpdxLicenses { impl SpdxLicenses { pub fn new() -> Self { - todo!() + let mut this = SpdxLicenses { + licenses: IndexMap::new(), + exceptions: IndexMap::new(), + }; + this.load_licenses(); + this.load_exceptions(); + this } - pub fn validate(&self, _license: &str) -> bool { - todo!() - } + /// Returns license metadata by license identifier. + /// + /// The returned list is in the form of: + /// [ 0 => full name, 1 => osi certified, 2 => link to license text, 3 => deprecation status ] + pub fn get_license_by_identifier(&self, identifier: &str) -> Option<PhpMixed> { + let key = identifier.to_lowercase(); - pub fn get_license_by_identifier(&self, _identifier: &str) -> Option<PhpMixed> { - todo!() + let (identifier, name, is_osi_approved, is_deprecated_license_id) = + self.licenses.get(&key)?; + + Some(PhpMixed::List(vec![ + PhpMixed::String(name.clone()), + PhpMixed::Bool(*is_osi_approved), + PhpMixed::String(format!( + "https://spdx.org/licenses/{}.html#licenseText", + identifier + )), + PhpMixed::Bool(*is_deprecated_license_id), + ])) } + /// Returns all licenses information, keyed by the lowercased license identifier. + /// + /// Each item is [ 0 => identifier, 1 => full name, 2 => osi certified, 3 => deprecated ]. pub fn get_licenses(&self) -> PhpMixed { - todo!() + let mut out: IndexMap<String, PhpMixed> = IndexMap::new(); + for (key, (identifier, name, is_osi_approved, is_deprecated_license_id)) in &self.licenses { + out.insert( + key.clone(), + PhpMixed::List(vec![ + PhpMixed::String(identifier.clone()), + PhpMixed::String(name.clone()), + PhpMixed::Bool(*is_osi_approved), + PhpMixed::Bool(*is_deprecated_license_id), + ]), + ); + } + PhpMixed::Array(out) + } + + /// Returns license exception metadata by license exception identifier. + /// + /// The returned list is in the form of: + /// [ 0 => full name, 1 => link to license text ] + pub fn get_exception_by_identifier(&self, identifier: &str) -> Option<PhpMixed> { + let key = identifier.to_lowercase(); + + let (identifier, name) = self.exceptions.get(&key)?; + + Some(PhpMixed::List(vec![ + PhpMixed::String(name.clone()), + PhpMixed::String(format!( + "https://spdx.org/licenses/{}.html#licenseExceptionText", + identifier + )), + ])) + } + + /// Returns the short identifier of a license (or license exception) by full name. + pub fn get_identifier_by_name(&self, name: &str) -> Option<String> { + for (identifier, full_name, _, _) in self.licenses.values() { + if full_name == name { + return Some(identifier.clone()); + } + } + + for (identifier, full_name) in self.exceptions.values() { + if full_name == name { + return Some(identifier.clone()); + } + } + + None + } + + /// Returns the OSI Approved status for a license by identifier. + pub fn is_osi_approved_by_identifier(&self, identifier: &str) -> bool { + self.licenses[&identifier.to_lowercase()].2 + } + + /// Returns the deprecation status for a license by identifier. + pub fn is_deprecated_by_identifier(&self, identifier: &str) -> bool { + self.licenses[&identifier.to_lowercase()].3 + } + + pub fn validate(&self, license: &str) -> bool { + // PHP also accepts string[] and joins it with ' OR '. The Rust signature here takes a single + // &str, matching the string-input path of the PHP code; the array path lives in callers. + self.is_valid_license_string(license) + } + + fn load_licenses(&mut self) { + let parsed: IndexMap<String, (String, bool, bool)> = + serde_json::from_str(LICENSES_JSON).expect("Missing or invalid license file"); + for (identifier, license) in parsed { + self.licenses.insert( + identifier.to_lowercase(), + (identifier, license.0, license.1, license.2), + ); + } + } + + fn load_exceptions(&mut self) { + let parsed: IndexMap<String, (String,)> = + serde_json::from_str(EXCEPTIONS_JSON).expect("Missing or invalid exceptions file"); + for (identifier, exception) in parsed { + self.exceptions + .insert(identifier.to_lowercase(), (identifier, exception.0)); + } + } + + fn is_valid_license_string(&self, license: &str) -> bool { + if self.licenses.contains_key(&license.to_lowercase()) { + return true; + } + + // The remaining validation matches a license expression against a recursive PCRE pattern + // (DEFINE subpatterns plus `(?&name)` recursion for compound AND/OR/WITH expressions). The + // `regex` crate cannot express recursive subpatterns, so this branch needs a hand-written + // SPDX expression parser. Left unported pending that work. + todo!("port SPDX license-expression grammar; recursive PCRE not expressible in regex crate") } } diff --git a/crates/shirabe/tests/json/json_file_test.rs b/crates/shirabe/tests/json/json_file_test.rs index d3e1048..aea34ba 100644 --- a/crates/shirabe/tests/json/json_file_test.rs +++ b/crates/shirabe/tests/json/json_file_test.rs @@ -43,70 +43,60 @@ fn create_temp_file() -> tempfile::NamedTempFile { } #[test] -#[ignore] fn test_parse_error_detect_extra_comma() { let json = "{\n \"foo\": \"bar\",\n}"; expect_parse_exception("Parse error on line 2", json); } #[test] -#[ignore] fn test_parse_error_detect_extra_comma_in_array() { let json = "{\n \"foo\": [\n \"bar\",\n ]\n}"; expect_parse_exception("Parse error on line 3", json); } #[test] -#[ignore] fn test_parse_error_detect_unescaped_backslash() { let json = "{\n \"fo\\o\": \"bar\"\n}"; expect_parse_exception("Parse error on line 1", json); } #[test] -#[ignore] fn test_parse_error_skips_escaped_backslash() { let json = "{\n \"fo\\\\o\": \"bar\"\n \"a\": \"b\"\n}"; expect_parse_exception("Parse error on line 2", json); } #[test] -#[ignore] fn test_parse_error_detect_single_quotes() { let json = "{\n 'foo': \"bar\"\n}"; expect_parse_exception("Parse error on line 1", json); } #[test] -#[ignore] fn test_parse_error_detect_missing_quotes() { let json = "{\n foo: \"bar\"\n}"; expect_parse_exception("Parse error on line 1", json); } #[test] -#[ignore] fn test_parse_error_detect_array_as_hash() { let json = "{\n \"foo\": [\"bar\": \"baz\"]\n}"; expect_parse_exception("Parse error on line 2", json); } #[test] -#[ignore] fn test_parse_error_detect_missing_comma() { let json = "{\n \"foo\": \"bar\"\n \"bar\": \"foo\"\n}"; expect_parse_exception("Parse error on line 2", json); } #[test] -#[ignore] fn test_parse_error_detect_missing_comma_multiline() { let json = "{\n \"foo\": \"barbar\"\n\n \"bar\": \"foo\"\n}"; expect_parse_exception("Parse error on line 2", json); } #[test] -#[ignore] fn test_parse_error_detect_missing_colon() { let json = "{\n \"foo\": \"bar\",\n \"bar\" \"foo\"\n}"; expect_parse_exception("Parse error on line 3", json); |
